Introduction
Every secure application must answer two fundamental questions: "Who are you?" and "What are you allowed to do?" These questions define authentication and authorization — the two pillars of application security. Despite being closely related, they serve fundamentally different purposes and require different implementation strategies. Confusing the two, or implementing one without the other, leads to security vulnerabilities that can compromise your entire application.
Authentication verifies identity. It answers the question "Is this user who they claim to be?" Authentication mechanisms include passwords, biometrics, multi-factor authentication (MFA), social login (OAuth), and magic links. The output of authentication is a verified identity — typically represented as a session, token, or user object.
Authorization verifies permissions. It answers the question "Is this user allowed to perform this action?" Authorization mechanisms include role-based access control (RBAC), attribute-based access control (ABAC), access control lists (ACLs), and policy engines. The output of authorization is a boolean — allowed or denied.
This guide covers both concepts in depth, from the authentication flow and token management to authorization models and their implementation. We'll examine JWT, OAuth 2.0, OpenID Connect, RBAC, ABAC, and real-world patterns for building secure applications.
Understanding Authentication: Identity Verification
Authentication Factors and Multi-Factor Authentication
Authentication factors fall into three categories, each representing a fundamentally different type of proof:
- Something you know — Passwords, PINs, security questions, passphrases
- Something you have — Phone (SMS/TOTP), hardware key (YubiKey), authenticator app, smart card
- Something you are — Fingerprint, face recognition, voice pattern, iris scan
Multi-factor authentication (MFA) combines factors from different categories. A password plus a TOTP code is two-factor authentication (2FA). The security strength increases exponentially with each additional factor because an attacker must compromise multiple independent channels.
Modern MFA implementations go beyond simple SMS codes. Time-based One-Time Passwords (TOTP) generated by apps like Google Authenticator or Authy provide stronger security than SMS, which is vulnerable to SIM-swapping attacks. Hardware security keys using FIDO2/U2F protocols provide phishing-resistant authentication by cryptographically binding the authentication to the origin domain.
import { authenticator } from 'otplib';
// Generate a TOTP secret for a user during enrollment
function enrollMFA(userId: string) {
const secret = authenticator.generateSecret();
// Store secret associated with user (encrypted at rest)
storeUserSecret(userId, secret);
const otpauthUrl = authenticator.keyuri(userId, 'MyApp', secret);
return { secret, otpauthUrl }; // otpauthUrl used to generate QR code
}
// Verify a TOTP code during login
function verifyMFA(userId: string, token: string): boolean {
const secret = getUserSecret(userId);
return authenticator.verify({ token, secret });
}Password Hashing and Storage
Never store passwords in plaintext. Use adaptive hashing algorithms designed specifically for password storage: bcrypt, scrypt, or Argon2id. These algorithms are intentionally slow and memory-hard, making brute-force attacks computationally expensive.
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12; // Cost factor — higher is slower but more secure
async function hashPassword(plaintext: string): Promise<string> {
return bcrypt.hash(plaintext, SALT_ROUNDS);
}
async function verifyPassword(plaintext: string, hash: string): Promise<boolean> {
return bcrypt.compare(plaintext, hash);
}
// Registration flow
async function registerUser(email: string, password: string) {
// Enforce password complexity
if (password.length < 12) {
throw new Error('Password must be at least 12 characters');
}
const passwordHash = await hashPassword(password);
return createUser({ email, passwordHash });
}Session-Based vs Token-Based Authentication
Session-based authentication stores session state on the server. The client receives a session ID (typically in an HTTP-only cookie), and the server looks up the session on each request. This is the traditional approach used by server-rendered web applications. The server maintains full control over session lifecycle — it can invalidate sessions at any time, enforce concurrent session limits, and track active sessions per user.
Token-based authentication embeds the identity information in the token itself (JWT). The server validates the token's signature without storing session state. This is the stateless approach used by APIs, single-page applications, and mobile apps. The tradeoff is that tokens cannot be revoked without additional infrastructure (a token blocklist or short expiration with refresh tokens).
import jwt from 'jsonwebtoken';
interface UserPayload {
id: string;
email: string;
roles: string[];
}
function generateTokens(user: UserPayload) {
const accessToken = jwt.sign(
{ sub: user.id, email: user.email, roles: user.roles },
process.env.JWT_SECRET!,
{ expiresIn: '15m', algorithm: 'RS256' }
);
const refreshToken = jwt.sign(
{ sub: user.id, type: 'refresh', jti: generateUniqueId() },
process.env.JWT_REFRESH_SECRET!,
{ expiresIn: '7d' }
);
// Store refresh token hash for rotation tracking
storeRefreshTokenHash(user.id, hashToken(refreshToken));
return { accessToken, refreshToken };
}
async function login(email: string, password: string) {
const user = await findUserByEmail(email);
if (!user || !await verifyPassword(password, user.passwordHash)) {
await recordFailedLoginAttempt(email);
throw new Error('Invalid credentials');
}
// Check account lockout
if (await isAccountLocked(email)) {
throw new Error('Account temporarily locked due to too many failed attempts');
}
await resetFailedLoginAttempts(email);
return generateTokens(user);
}OAuth 2.0 and OpenID Connect
OAuth 2.0 is an authorization framework that enables third-party applications to access resources on behalf of a user. It defines four grant types (now called "authorization flows" in OAuth 2.1):
- Authorization Code — The recommended flow for server-side apps. The client receives an authorization code, then exchanges it for tokens server-side. PKCE (Proof Key for Code Exchange) is now required even for public clients.
- Authorization Code with PKCE — Required for public clients (SPAs, mobile apps). Prevents authorization code interception attacks.
- Client Credentials — For machine-to-machine communication where no user is involved.
- Device Authorization — For input-constrained devices (smart TVs, CLI tools).
OpenID Connect (OIDC) is an authentication layer on top of OAuth 2.0. While OAuth 2.0 handles authorization (what can this app access), OIDC handles authentication (who is this user). OIDC adds an ID token (JWT) that contains the user's identity claims (sub, email, name, picture).
// Authorization Code Flow with PKCE
import crypto from 'crypto';
function generatePKCE() {
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
return { codeVerifier, codeChallenge };
}
// Step 1: Redirect to authorization server
app.get('/auth/login', (req, res) => {
const { codeVerifier, codeChallenge } = generatePKCE();
req.session.codeVerifier = codeVerifier;
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.OAUTH_CLIENT_ID!,
redirect_uri: process.env.OAUTH_REDIRECT_URI!,
scope: 'openid profile email',
state: generateState(),
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
res.redirect(`${process.env.OAUTH_ISSUER}/authorize?${params}`);
});
// Step 2: Handle callback and exchange code for tokens
app.get('/auth/callback', async (req, res) => {
const { code, state } = req.query;
if (state !== req.session.state) {
return res.status(400).json({ error: 'Invalid state — possible CSRF attack' });
}
const tokenResponse = await fetch(`${process.env.OAUTH_ISSUER}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code as string,
redirect_uri: process.env.OAUTH_REDIRECT_URI!,
client_id: process.env.OAUTH_CLIENT_ID!,
client_secret: process.env.OAUTH_CLIENT_SECRET!,
code_verifier: req.session.codeVerifier,
}),
});
const { access_token, id_token, refresh_token } = await tokenResponse.json();
// Validate the ID token
const userInfo = await validateIdToken(id_token);
req.session.user = userInfo;
req.session.accessToken = access_token;
res.redirect('/dashboard');
});Passkeys and WebAuthn: The Passwordless Future
Passkeys represent the most significant shift in authentication since passwords. Based on the FIDO2/WebAuthn standard, passkeys use public-key cryptography to authenticate users without passwords. The private key stays on the user's device (secured by biometrics or device PIN), and the public key is registered with the service.
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
// Registration — create a new passkey
app.post('/auth/passkey/register', async (req, res) => {
const user = req.user;
const options = generateRegistrationOptions({
rpName: 'My Application',
rpID: req.hostname,
userID: user.id,
userName: user.email,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
req.session.challenge = options.challenge;
res.json(options);
});
// Authentication — login with passkey
app.post('/auth/passkey/login', async (req, res) => {
const options = generateAuthenticationOptions({
rpID: req.hostname,
userVerification: 'preferred',
});
req.session.challenge = options.challenge;
res.json(options);
});Understanding Authorization: Access Control
Role-Based Access Control (RBAC)
RBAC assigns permissions through roles. A user has one or more roles (admin, editor, viewer), and each role grants a set of permissions. RBAC is simple to implement, easy to audit, and maps well to organizational hierarchies.
The key design decisions in RBAC are role granularity and role hierarchy. Too few roles create overly broad permissions; too many roles become unmanageable. Role hierarchy allows roles to inherit permissions from parent roles (e.g., an admin inherits all editor and viewer permissions).
// Role-Permission mapping
const ROLES: Record<string, { permissions: string[]; inherits?: string[] }> = {
viewer: {
permissions: ['posts:read', 'comments:read', 'profile:read'],
},
editor: {
permissions: ['posts:write', 'posts:publish', 'comments:write'],
inherits: ['viewer'],
},
admin: {
permissions: ['users:manage', 'settings:manage', 'posts:delete'],
inherits: ['editor'],
},
};
function getEffectivePermissions(roles: string[]): Set<string> {
const permissions = new Set<string>();
function resolveRole(roleName: string) {
const role = ROLES[roleName];
if (!role) return;
role.permissions.forEach(p => permissions.add(p));
role.inherits?.forEach(resolveRole);
}
roles.forEach(resolveRole);
return permissions;
}
// Authorization middleware
function authorize(...requiredPermissions: string[]) {
return (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const userPermissions = getEffectivePermissions(req.user.roles);
const hasPermission = requiredPermissions.every(
perm => userPermissions.has(perm)
);
if (!hasPermission) {
return res.status(403).json({
error: 'Insufficient permissions',
required: requiredPermissions,
});
}
next();
};
}
// Usage
app.delete('/api/users/:id', authenticate, authorize('users:manage'), deleteUser);
app.put('/api/posts/:id', authenticate, authorize('posts:write'), updatePost);Attribute-Based Access Control (ABAC)
ABAC makes access decisions based on attributes of the user, resource, action, and environment. Policies are expressed as conditions on attributes, making ABAC far more flexible than RBAC for complex, context-dependent access control.
ABAC is the right choice when access decisions depend on contextual factors: the time of day, the user's department, the resource's classification level, the user's location, or the relationship between the user and the resource owner.
interface Policy {
id: string;
effect: 'allow' | 'deny';
target: {
resource?: string;
action?: string;
};
conditions: Condition[];
}
interface Condition {
attribute: string; // e.g., 'user.department', 'resource.classification'
operator: 'equals' | 'not_equals' | 'in' | 'greaterThan' | 'contains';
value: any;
}
function evaluateABAC(
policies: Policy[],
context: {
user: Record<string, any>;
resource: Record<string, any>;
action: string;
environment: Record<string, any>;
}
): 'allow' | 'deny' {
// Default deny — if no policy matches, access is denied
let decision: 'allow' | 'deny' = 'deny';
for (const policy of policies) {
// Check if policy targets match
if (policy.target.resource && policy.target.resource !== context.resource.type) continue;
if (policy.target.action && policy.target.action !== context.action) continue;
// Evaluate all conditions
const allConditionsMet = policy.conditions.every(condition => {
const attributeValue = resolveAttribute(condition.attribute, context);
return evaluateCondition(attributeValue, condition.operator, condition.value);
});
if (allConditionsMet) {
decision = policy.effect;
// Explicit deny overrides allow (deny takes precedence)
if (decision === 'deny') break;
}
}
return decision;
}
// Example: A doctor can view patient records only during work hours
// and only for patients assigned to them
const medicalRecordPolicies: Policy[] = [
{
id: 'doctor-patient-access',
effect: 'allow',
target: { resource: 'medical-record', action: 'read' },
conditions: [
{ attribute: 'user.role', operator: 'equals', value: 'doctor' },
{ attribute: 'resource.assignedDoctorId', operator: 'equals', value: '${user.id}' },
{ attribute: 'environment.isWorkHours', operator: 'equals', value: true },
],
},
];Access Control Lists (ACLs)
ACLs define permissions directly on resources. Each resource has a list of (principal, permission) pairs. ACLs are simple and intuitive for resource-level permissions but become difficult to manage at scale because permissions are scattered across every resource.
interface ACL {
resourceId: string;
entries: ACLEntry[];
}
interface ACLEntry {
principal: string; // userId or groupId
principalType: 'user' | 'group';
permissions: string[]; // ['read', 'write', 'delete']
}
async function checkACL(
userId: string,
resourceId: string,
requiredPermission: string
): Promise<boolean> {
const acl = await getACL(resourceId);
if (!acl) return false;
const userGroups = await getUserGroups(userId);
return acl.entries.some(entry => {
const isMatch =
(entry.principalType === 'user' && entry.principal === userId) ||
(entry.principalType === 'group' && userGroups.includes(entry.principal));
return isMatch && entry.permissions.includes(requiredPermission);
});
}Policy Engines: OPA, Casbin, and AWS Cedar
For complex authorization requirements, dedicated policy engines separate authorization logic from application code. Policies are written in a declarative language, stored in version control, and evaluated by a dedicated engine.
# Open Policy Agent (OPA) — Rego policy
package authz
default allow = false
# Admins can do anything
allow {
input.user.roles[_] == "admin"
}
# Editors can edit their own posts
allow {
input.action == "edit"
input.user.roles[_] == "editor"
input.resource.author_id == input.user.id
}
# Viewers can read published posts
allow {
input.action == "read"
input.resource.published == true
}// Casbin — Node.js policy enforcement
import { newEnforcer } from 'casbin';
const enforcer = await newEnforcer('model.conf', 'policy.csv');
async function checkPermission(userId: string, resource: string, action: string): Promise<boolean> {
return enforcer.enforce(userId, resource, action);
}
// model.conf
// [request_definition]
// r = sub, obj, act
//
// [policy_definition]
// p = sub, obj, act
//
// [policy_effect]
// e = some(where (p.eft == allow))
//
// [matchers]
// m = r.sub == p.sub && r.obj == p.obj && r.act == p.actArchitecture Patterns
The API Gateway Pattern
Centralize authentication at the API gateway level. The gateway validates tokens, extracts user identity, and passes identity information to downstream services via trusted headers. This ensures consistent authentication across all services and centralizes token validation logic.
// API Gateway authentication middleware
app.use(async (req, res, next) => {
const token = extractBearerToken(req);
if (!token) return res.status(401).json({ error: 'Missing token' });
try {
const payload = await verifyJWT(token);
// Forward identity to downstream services via trusted headers
req.headers['x-user-id'] = payload.sub;
req.headers['x-user-roles'] = payload.roles.join(',');
req.headers['x-user-email'] = payload.email;
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
});The Claims-Based Pattern
Encode user identity and permissions as claims in a JWT. Each service reads the claims to make authorization decisions without calling a central auth service. This is efficient but requires careful claim design and token size management.
The Zero Trust Pattern
Never trust, always verify. Every request is authenticated and authorized regardless of network location. Services authenticate to each other using mutual TLS (mTLS) or service tokens. This eliminates the implicit trust of network perimeters.
// Zero Trust: Service-to-service authentication with mTLS
import https from 'https';
import fs from 'fs';
const serviceClient = new https.Agent({
cert: fs.readFileSync('/etc/ssl/service.crt'),
key: fs.readFileSync('/etc/ssl/service.key'),
ca: fs.readFileSync('/etc/ssl/ca.crt'),
rejectUnauthorized: true,
});
async function callDownstreamService(url: string, payload: any) {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Service-Token': await getServiceToken(),
},
body: JSON.stringify(payload),
// @ts-ignore — agent for mTLS
agent: serviceClient,
});
return response.json();
}Real-World Use Cases
SaaS Multi-Tenancy
SaaS applications need tenant isolation — users from one organization cannot access another organization's data. Implement tenant-scoped authorization by including the tenant ID in the JWT claims and validating it on every request. Use row-level security (RLS) in your database to enforce tenant isolation at the data layer.
// Tenant-scoped authorization
function authorizeTenant(req: AuthenticatedRequest, res: Response, next: NextFunction) {
const requestTenantId = req.params.tenantId || req.headers['x-tenant-id'];
const userTenantId = req.user?.tenantId;
if (!requestTenantId || requestTenantId !== userTenantId) {
return res.status(403).json({ error: 'Access denied: tenant mismatch' });
}
next();
}
// Row-level security in PostgreSQL
// CREATE POLICY tenant_isolation ON documents
// USING (tenant_id = current_setting('app.current_tenant')::uuid);API Rate Limiting by Tier
Different subscription tiers get different rate limits. Use RBAC to assign tiers (free, pro, enterprise) and authorization middleware to enforce per-tier limits. This combines authorization with resource management.
Healthcare Applications (HIPAA)
HIPAA requires strict access controls. ABAC is the right model — access decisions based on the user's role, the patient's relationship to the user, the sensitivity of the data, and the time of access. Audit logging is mandatory — every access to patient data must be recorded with the who, what, when, and why.
Security Best Practices
Token Security
- Use short-lived access tokens — 15-60 minutes. Use refresh tokens (7-30 days, stored securely) to obtain new access tokens.
- Implement refresh token rotation — Each refresh issues a new refresh token and invalidates the old one. If a stolen refresh token is reused, all tokens in the family are invalidated.
- Store tokens in HTTP-only, secure, SameSite cookies — Never store tokens in localStorage (vulnerable to XSS).
- Validate all JWT claims — Verify signature, expiration (
exp), issuer (iss), audience (aud), and not-before (nbf).
Password Security
- Use bcrypt, scrypt, or Argon2id with appropriate cost factors.
- Enforce minimum 12-character passwords — Length beats complexity. A 20-character passphrase is stronger than an 8-character password with special characters.
- Check passwords against breached password databases (HaveIBeenPwned API) during registration and login.
- Implement progressive lockout — After 5 failed attempts, require CAPTCHA. After 10, lock the account for 15 minutes.
Authorization Security
- Default deny — If no policy explicitly allows access, deny it.
- Validate authorization server-side — Never trust client-side role/permission checks.
- Use the principle of least privilege — Grant only the minimum permissions needed.
- Audit all authorization decisions — Log allow and deny decisions with full context.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Storing JWTs in localStorage | XSS can steal tokens | Use HTTP-only cookies |
| Long-lived access tokens | Large compromise window | Short access tokens + refresh rotation |
| Not validating JWT claims | Token forgery | Validate iss, aud, exp, nbf, signature |
| Confusing authn and authz | Security gaps | Separate middleware for each concern |
| Hardcoding roles in business logic | Inflexible, hard to audit | Use centralized policy engine |
| No account lockout | Brute-force vulnerability | Implement progressive lockout |
| Missing HTTPS | Token interception | Enforce HTTPS everywhere with HSTS |
| Client-side authorization only | Trivially bypassed | Always enforce authorization server-side |
| SameSite=None cookies | CSRF vulnerability | Use SameSite=Strict or Lax |
Comparison of Authorization Models
| Model | Flexibility | Complexity | Auditability | Best For |
|---|---|---|---|---|
| RBAC | Medium | Low | High | Simple role-based systems |
| ABAC | High | High | Medium | Complex, context-dependent policies |
| ACL | Low | Low | High | Resource-level permissions |
| Policy Engine | Very High | Medium | Very High | Enterprise, compliance-heavy |
| ReBAC | High | Medium | High | Social graphs, document sharing |
Advanced Patterns
Relationship-Based Access Control (ReBAC)
ReBAC makes authorization decisions based on relationships between users and resources. "Can this user view this document?" depends on whether the user is the document's owner, a member of the document's workspace, or has been explicitly shared with. Google Zanzibar (the system behind Google Drive and Docs permissions) is the canonical example.
Delegated Authorization
Allow users to delegate their permissions to other users or services. OAuth 2.0's delegation model handles this for third-party apps, but internal delegation (user A grants user B read access to their documents) requires custom implementation. Implement delegation chains with depth limits to prevent abuse.
Policy as Code
Define authorization policies in version-controlled files, test them in CI/CD pipelines, and deploy them through the same process as application code. Tools like OPA, Casbin, and AWS Cedar support this pattern. Policy changes go through code review, and policy tests catch regressions before deployment.
Future Outlook
Authentication is moving toward passwordless — passkeys (WebAuthn/FIDO2) replace passwords with cryptographic key pairs stored on the user's device. Apple, Google, and Microsoft are collaborating on passkey synchronization across devices, making passwordless authentication seamless across the ecosystem.
Passkeys Implementation
Passkeys represent the most significant shift in authentication since the introduction of OAuth. They eliminate passwords entirely, using public-key cryptography where the private key never leaves the user's device.
// Register a new passkey
async function registerPasskey(username: string) {
const challenge = await fetch('/api/auth/challenge').then(r => r.arrayBuffer());
const credential = await navigator.credentials.create({
publicKey: {
challenge,
rp: { name: "My App", id: "example.com" },
user: {
id: new TextEncoder().encode(username),
name: username,
displayName: username
},
pubKeyCredParams: [
{ alg: -7, type: "public-key" }, // ES256
{ alg: -257, type: "public-key" } // RS256
],
authenticatorSelection: {
residentKey: "required",
userVerification: "preferred"
}
}
});
// Send credential to server for storage
await fetch('/api/auth/register-passkey', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential)
});
}
// Authenticate with passkey
async function authenticateWithPasskey() {
const challenge = await fetch('/api/auth/challenge').then(r => r.arrayBuffer());
const assertion = await navigator.credentials.get({
publicKey: {
challenge,
rpId: "example.com",
userVerification: "preferred"
}
});
// Send assertion to server for verification
const result = await fetch('/api/auth/verify-passkey', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(assertion)
});
return result.json(); // Returns session token
}Multi-Factor Authentication (MFA)
MFA adds layers of security by requiring multiple verification methods. The most common implementation combines a password (something you know) with a TOTP code (something you have).
import { authenticator } from 'otplib';
// Generate MFA secret for user
function generateMFASecret(userEmail: string) {
const secret = authenticator.generateSecret();
const otpauth = authenticator.keyuri(userEmail, 'MyApp', secret);
return { secret, otpauth }; // otpauth used for QR code generation
}
// Verify MFA code
function verifyMFACode(secret: string, token: string): boolean {
return authenticator.verify({ token, secret });
}
// Login flow with MFA
async function login(email: string, password: string, mfaCode?: string) {
const user = await verifyCredentials(email, password);
if (user.mfaEnabled) {
if (!mfaCode) {
return { requiresMFA: true, challenge: 'enter-code' };
}
if (!verifyMFACode(user.mfaSecret, mfaCode)) {
throw new Error('Invalid MFA code');
}
}
return generateSession(user);
}Authorization is moving toward policy as code and fine-grained authorization (FGA) — authorization policies defined in version-controlled files, tested in CI/CD pipelines, and evaluated by dedicated policy engines. OpenFGA (based on Google Zanzibar) and SpiceDB are leading this shift, enabling relationship-based access control at scale.
Conclusion
Authentication and authorization are the foundation of application security. Authentication verifies identity, authorization verifies permissions. Both are required for a secure application.
Key takeaways:
- Authentication (who) and authorization (what) are separate concerns — implement them independently
- Use JWTs with short expiration for stateless authentication; use refresh token rotation for session continuity
- Implement RBAC for simple permission models; use ABAC for complex, context-dependent policies
- Store tokens in HTTP-only, secure cookies — never in localStorage
- Validate all JWT claims (signature, exp, iss, aud) on every request
- Use OAuth 2.0/OpenID Connect for third-party authentication with PKCE for all public clients
- Log all authentication and authorization events for security monitoring and compliance
- Adopt passkeys for passwordless authentication to eliminate password-based attacks
- Use policy engines (OPA, Casbin, Cedar) to separate authorization logic from business logic
- Implement defense in depth — combine multiple security layers rather than relying on any single mechanism
Start by implementing basic JWT authentication with bcrypt password hashing. Add RBAC middleware for authorization. Then layer on MFA, OAuth integration, passkeys, and advanced authorization policies as your application's security requirements grow.