Authentication code sits on the boundary between an attacker and every capability an application exposes. A design can use strong cryptography and still fail because it confuses identity with permission, trusts an unverified token, stores a bearer credential where scripts can read it, or implements an OAuth redirect without binding the response to the browser that started it.

There is no universally best credential. A first-party web application often benefits from an opaque server-side session. A distributed API may need short-lived access tokens. Delegated access to another product calls for OAuth 2.0, while login through an identity provider requires the identity layer supplied by OpenID Connect. The engineering task is to choose the smallest protocol that fits, validate every boundary, and retain a way to expire compromised authority.

Authentication establishes identity; authorization limits action

Authentication answers “which principal is making this request?” A principal may be a person, service, device, or workload. Passwords, passkeys, a session cookie, a client certificate, and a verified token are ways to establish or carry that identity.

Authorization answers “may this principal perform this action on this resource now?” It considers roles or scopes, resource ownership, tenant boundaries, account state, and contextual policy. Authentication should produce a small, trustworthy identity such as { subject, tenantId, assurance }; the business operation should then authorize against current data.

ts
type Principal = {
  subject: string;
  tenantId: string;
  scopes: ReadonlySet<string>;
};

async function deleteInvoice(principal: Principal, invoiceId: string) {
  const invoice = await invoices.findById(invoiceId);
  if (!invoice || invoice.tenantId !== principal.tenantId) {
    throw new HttpError(404, 'invoice not found');
  }
  if (!principal.scopes.has('invoice:delete')) {
    throw new HttpError(403, 'forbidden');
  }
  await invoices.delete(invoiceId);
}

Checking only role === 'admin' at the route layer is rarely sufficient. Object-level authorization prevents insecure direct object references, and tenant checks must derive from the authenticated principal rather than a caller-controlled header. Return 401 Unauthorized when valid authentication is absent and 403 Forbidden when an authenticated principal lacks permission. Some systems intentionally return 404 to avoid revealing that a resource exists.

Step-up authentication is useful for sensitive actions. A remembered session may be enough to read a profile but not to rotate recovery codes or approve a transfer. Record authentication time and assurance, then require a recent passkey, password, or second factor without treating possession of an old session as permanent proof.

Server sessions and browser cookies

A session cookie should contain an unpredictable opaque identifier, not user data. The server stores the session record and can revoke it immediately. Generate at least 128 bits with a cryptographically secure random source, store only a hash of the identifier, and rotate it after login or privilege changes to prevent session fixation.

ts
import { createHash, randomBytes } from 'node:crypto';

type Session = {
  userId: string;
  expiresAt: Date;
};

const digest = (value: string) =>
  createHash('sha256').update(value, 'utf8').digest('hex');

async function createSession(userId: string): Promise<string> {
  const id = randomBytes(32).toString('base64url');
  await sessionStore.insert({
    idHash: digest(id),
    userId,
    expiresAt: new Date(Date.now() + 8 * 60 * 60 * 1000),
  });
  return id;
}

async function authenticateSession(id: string): Promise<Session | null> {
  const session = await sessionStore.findByHash(digest(id));
  if (!session || session.expiresAt.getTime() <= Date.now()) return null;
  return session;
}

function sessionCookie(id: string, maxAgeSeconds: number): string {
  return [
    `__Host-session=${encodeURIComponent(id)}`,
    'Path=/',
    'HttpOnly',
    'Secure',
    'SameSite=Lax',
    `Max-Age=${maxAgeSeconds}`,
  ].join('; ');
}

The __Host- prefix requires Secure, Path=/, and no Domain, preventing a subdomain from setting a wider cookie. HttpOnly stops JavaScript from reading the value; it does not stop injected JavaScript from issuing authenticated requests. Secure protects transport after HTTPS is correctly deployed. Use an absolute lifetime, optionally an idle lifetime, and delete both the record and cookie on logout. In a cluster, use a consistent shared store or deliberate sticky routing; do not silently create independent in-memory session islands.

Cookies are attached automatically, so state-changing requests need CSRF protection. SameSite=Lax is a valuable baseline but is not a complete policy for every browser flow. Check Origin on unsafe methods and use a synchronizer token stored in the session, or a correctly implemented signed double-submit token. Do not mutate state through GET. CORS does not prevent ordinary cross-site form submissions.

Warning

Never place session identifiers, authorization codes, or bearer tokens in URLs. URLs leak through history, logs, analytics, screenshots, and referrer headers. Reject credentials from query parameters unless a protocol explicitly requires a short-lived code on a validated callback.

Property Server session Self-contained JWT access token
Request lookup Usually one cache or database read Local signature and claim verification
Immediate revocation Natural: delete or disable the record Requires short expiry, introspection, or a denylist
Payload freshness Current server-side state Claims remain stale until replacement
Horizontal scaling Shared store or routing strategy Verification keys distributed to services
Browser default Strong fit with an HttpOnly cookie Often safer behind a backend-for-frontend
Main failure mode Store outage or fixation/CSRF mistakes Validation gaps, theft, long lifetime, key mistakes

JWT validation, rotation, and revocation

A JSON Web Token is a serialization format, not an authentication strategy. A signed JWT is trustworthy only after the receiver validates its cryptographic signature and the claims required by the local protocol. Base64-decoding the three segments proves nothing. The resource server must select algorithms explicitly, trust keys from a configured issuer, and verify issuer, audience, expiry, and the token’s intended use.

ts
import { createRemoteJWKSet, jwtVerify } from 'jose';

const issuer = 'https://identity.example.com/';
const audience = 'https://api.example.com';
const jwks = createRemoteJWKSet(
  new URL('https://identity.example.com/.well-known/jwks.json'),
);

export async function verifyAccessToken(authorization?: string) {
  const token = /^Bearer ([A-Za-z0-9._~-]+)$/.exec(authorization ?? '')?.[1];
  if (!token) throw new HttpError(401, 'missing bearer token');

  const { payload, protectedHeader } = await jwtVerify(token, jwks, {
    issuer,
    audience,
    algorithms: ['RS256'],
    clockTolerance: '5s',
    requiredClaims: ['sub', 'iat', 'exp'],
  });

  if (typeof payload.sub !== 'string') {
    throw new HttpError(401, 'invalid subject');
  }
  if (protectedHeader.typ && protectedHeader.typ !== 'at+jwt') {
    throw new HttpError(401, 'wrong token type');
  }

  const scopes = typeof payload.scope === 'string'
    ? new Set(payload.scope.split(' ').filter(Boolean))
    : new Set<string>();
  return { subject: payload.sub, scopes };
}

Cache JWKS according to HTTP metadata and rate-limit refreshes. During signing-key rotation, publish the new key before issuing tokens with it, retain the old public key until all old tokens expire, and use kid only to select among keys already trusted for that issuer. Never fetch an arbitrary key URL named by an untrusted token header.

Keep access tokens short-lived and narrowly scoped. Refresh tokens are longer-lived credentials presented only to the authorization server. Store refresh tokens as protected secrets; for public clients, rotate them on every use. Persist a hash, token family, expiry, client, subject, and consumed time. If a consumed refresh token appears again, assume theft, revoke the family, and require authentication. Rotation without reuse detection merely gives an attacker a fresh credential too.

JWT logout is not magic: deleting a browser copy does not invalidate another copy. For high-risk APIs, use opaque tokens with introspection, a denylist keyed by jti until expiry, or a current session/security-version lookup. Each adds state, which is often the correct cost for rapid revocation. Never put secrets or sensitive personal data in a JWT payload; signatures provide integrity, not confidentiality.

OAuth 2.0, OpenID Connect, and PKCE

OAuth 2.0 delegates access. The resource owner grants a client limited authority; the authorization server issues tokens; the resource server accepts an access token for an API. OAuth alone does not define a login identity. OpenID Connect (OIDC) adds an ID token, standardized identity claims, discovery, UserInfo, and the openid scope.

Artifact Audience and purpose Where it should go
Authorization code One-time input to the token endpoint Back to the validated client redirect URI
Access token Resource server; authorizes API calls In the Authorization: Bearer header
Refresh token Authorization server; obtains new tokens Client’s protected credential storage only
ID token OIDC client; describes an authentication event Client validation logic, never an API bearer token

Use the authorization-code flow with PKCE for browser, mobile, desktop, and server clients. PKCE binds a stolen authorization code to a verifier held by the initiating client. state binds the callback to the browser transaction and protects against login CSRF. OIDC nonce binds the ID token to that transaction and limits replay. They solve different problems; none replaces another.

ts
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { createRemoteJWKSet, jwtVerify } from 'jose';

const randomValue = () => randomBytes(32).toString('base64url');
const sha256 = (value: string) =>
  createHash('sha256').update(value, 'ascii').digest('base64url');

function equalSecret(left: string, right: string): boolean {
  const a = Buffer.from(left);
  const b = Buffer.from(right);
  return a.length === b.length && timingSafeEqual(a, b);
}

function beginLogin(session: LoginSession): string {
  const verifier = randomValue();
  const state = randomValue();
  const nonce = randomValue();
  session.oauth = { verifier, state, nonce, createdAt: Date.now() };

  const url = new URL('https://identity.example.com/authorize');
  url.search = new URLSearchParams({
    response_type: 'code',
    client_id: 'web-client',
    redirect_uri: 'https://app.example.com/oauth/callback',
    scope: 'openid profile email',
    code_challenge: sha256(verifier),
    code_challenge_method: 'S256',
    state,
    nonce,
  }).toString();
  return url.toString();
}

async function finishLogin(callback: URL, session: LoginSession) {
  const pending = session.oauth;
  delete session.oauth; // One attempt only, including provider errors.
  const returnedState = callback.searchParams.get('state') ?? '';
  if (!pending || Date.now() - pending.createdAt > 10 * 60_000 ||
      !equalSecret(returnedState, pending.state)) {
    throw new HttpError(400, 'invalid OAuth transaction');
  }
  if (callback.searchParams.has('error')) {
    throw new HttpError(400, 'authorization was not completed');
  }
  const code = callback.searchParams.get('code');
  if (!code) throw new HttpError(400, 'missing authorization code');

  const response = await fetch('https://identity.example.com/token', {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: 'web-client',
      redirect_uri: 'https://app.example.com/oauth/callback',
      code,
      code_verifier: pending.verifier,
    }),
  });
  if (!response.ok) throw new HttpError(401, 'token exchange failed');
  const tokens = await response.json() as { id_token?: string };
  if (!tokens.id_token) throw new HttpError(401, 'missing ID token');

  const { payload } = await jwtVerify(
    tokens.id_token,
    createRemoteJWKSet(new URL('https://identity.example.com/jwks')),
    {
      issuer: 'https://identity.example.com/',
      audience: 'web-client',
      algorithms: ['RS256'],
      requiredClaims: ['sub', 'iat', 'exp', 'nonce'],
    },
  );
  if (typeof payload.sub !== 'string' ||
      typeof payload.nonce !== 'string' ||
      !equalSecret(payload.nonce, pending.nonce)) {
    throw new HttpError(401, 'invalid ID token claims');
  }
  return payload.sub;
}

Confidential clients must authenticate to the token endpoint using the provider’s configured method, preferably private_key_jwt or mTLS when supported; never ship a client secret to browser code. Register exact redirect URIs and reject open redirect parameters. In production, use a maintained OIDC client library so discovery, iss, aud, azp, signature, time claims, nonce, and provider-specific error handling follow the relevant profile.

sequenceDiagram participant B as Browser participant C as Client backend participant A as Authorization server participant R as Resource server B->>C: Start login C->>C: Store state, nonce, PKCE verifier C-->>B: Redirect with state, nonce, PKCE challenge B->>A: Authenticate and grant consent A-->>B: Redirect with code and state B->>C: Callback with code and state C->>C: Validate and consume state C->>A: Code plus PKCE verifier A-->>C: ID token, access token, optional refresh token C->>C: Verify ID token signature, claims, and nonce C->>R: Access token R-->>C: Authorized resource

Service identity and credential storage

Machine-to-machine traffic should identify the workload, not impersonate a convenient human. Prefer platform workload identity with short-lived credentials, mTLS certificates, or OAuth client credentials for a confidential service. Scope each identity to one workload and audience. Static API keys are simpler but provide weaker identity and rotation; hash them at rest, show them once, prefix them for lookup, rate-limit them, and support overlapping rotation.

Use a secret manager or hardware-backed key service for client secrets and private keys. Restrict access, audit reads, rotate safely, and never commit credentials to source control or bake them into images. Databases should hold hashes of high-entropy session IDs, refresh tokens, recovery codes, and API keys so a read-only leak does not immediately yield bearer credentials. Passwords are different: hash them with a password-specific, memory-hard function such as Argon2id with calibrated parameters and a unique salt.

In a browser, storage is a threat-model choice. localStorage, sessionStorage, and IndexedDB are readable by any successful same-origin XSS. An HttpOnly cookie hides its value from script but is sent automatically and therefore needs CSRF controls. A backend-for-frontend can keep OAuth tokens server-side and expose only a hardened session cookie to the browser, often reducing the token-handling surface. Native applications should use the operating system’s secure credential store.

Prevent XSS with contextual output encoding, safe DOM APIs, sanitization for intentionally accepted HTML, dependency hygiene, and a restrictive Content Security Policy. Do not assume HttpOnly makes XSS harmless: malicious script can still perform actions, read page data, and alter authorization requests while it runs.

Common failures and a decision guide

Failure Consequence Control
Decode JWT without verifying it Attacker supplies arbitrary identity and scopes Verify signature, issuer, audience, algorithm, time, and token type
Long-lived bearer token in browser storage XSS creates durable account theft Short lifetimes; hardened cookies or backend-for-frontend
Session ID survives login Session fixation Rotate atomically after authentication and privilege changes
Cookie-authenticated mutation lacks CSRF control Cross-site action under the victim’s session SameSite, Origin checks, and CSRF token
Missing object or tenant check Horizontal or cross-tenant privilege escalation Authorize every resource against the principal
OAuth callback omits state/PKCE/nonce Login CSRF, code interception, or ID-token replay Generate, store, validate, expire, and consume each value
Redirect URI accepts arbitrary destinations Code or token disclosure Exact allowlist; no open redirect after callback
Logs contain cookies or tokens Anyone with log access can replay credentials Structured redaction and tested log policy

Choose a server session for a first-party browser application when immediate revocation and simple semantics matter. Choose short-lived OAuth access tokens for APIs called by multiple independent clients or services, especially when an authorization server already owns policy. Choose OIDC when the client needs federated login. Choose mTLS or workload identity for infrastructure service authentication. These choices can coexist: a backend-for-frontend may complete OIDC, keep provider tokens server-side, and issue its own opaque browser session.

Avoid JWT merely to remove a database lookup; key distribution, claim staleness, and revocation are operational state too. Avoid building an authorization server unless identity protocol implementation is the product: use a mature provider and maintained libraries. Whatever the choice, model credential theft, user disablement, key rotation, clock skew, multi-region outages, and logout before production. Test negative cases, not only the happy redirect.

Warning

Fail closed when credential validation is unavailable or ambiguous. Do not accept an expired token because the identity service is down, fall back to unsigned claims, or skip authorization to preserve availability. Design bounded caches and documented emergency procedures instead.

Takeaways

  • Authentication establishes a principal; authorization must still evaluate the requested action, resource, tenant, and current policy.
  • Opaque server sessions fit first-party web applications well. Use strong random IDs, hashed storage, rotation, explicit expiry, and hardened cookies.
  • Cookies reduce token exposure to JavaScript but require CSRF defenses; HttpOnly does not neutralize XSS.
  • JWT consumers must verify signature, issuer, audience, algorithm, time, and token purpose. Decoding is not verification, and revocation remains a design decision.
  • Keep access tokens short-lived. Rotate refresh tokens with reuse detection, and overlap trusted keys during signing-key rotation.
  • OAuth delegates API access; OIDC adds authentication. Authorization code with PKCE, state, and OIDC nonce binds every leg of the login transaction.
  • Use workload identity, mTLS, or scoped client credentials for services, and keep bearer credentials in purpose-built protected storage.
  • Prefer the simplest architecture that preserves expiry and revocation, then test theft, replay, cross-tenant access, failure, and rotation paths.