Designing Authentication for Modern Applications

A deep architectural guide to modern application authentication: WebAuthn, FIDO2 passkeys, session topologies, OIDC federation, and Zero Trust continuous verification.

September 13, 202519 min readOpenStair Engineering
Diagram showing modern authentication architecture across identity providers, session tokens, and WebAuthn authenticators

Designing Authentication for Modern Applications

Authentication in distributed systems has undergone a structural paradigm shift. In early web architectures, authentication was largely a solved problem: an HTTP server validated credentials against a relational database, generated an opaque session identifier, wrote it to an HttpOnly cookie, and persisted the session in server memory or a shared key-value cache.

Modern applications operate under radically different constraints. Monolithic web frontends have been superseded by single-page applications (SPAs), native mobile clients, federated third-party integrations, and decoupled microservices. Concurrently, attacker capabilities have scaled from automated brute-force attacks to industrial credential stuffing, automated SIM-swapping, and reverse-proxy Adversary-in-the-Middle (AiTM) phishing frameworks capable of intercepting both credentials and multi-factor authentication (MFA) tokens in real time.

Designing authentication for production environments requires engineering beyond the simple "login endpoint." It demands selecting cryptographic primitives that withstand hardware-accelerated cracking, adopting phishing-resistant credentials, structuring session topologies that balance latency against instantaneous revocation, and enforcing continuous contextual security through Zero Trust principles.


1. The Evolution of Identity Credentials & Threat Models

Every authentication mechanism represents an engineering trade-off between user friction, implementation complexity, and vulnerability to specific threat vectors. To design a resilient system, backend architects must understand where traditional credential mechanisms fail and how modern standards resolve those vulnerabilities.

Credential Vulnerability & Evolution Spectrum:

[ Passwords ] ───────> [ SMS / Voice OTP ] ───────> [ TOTP (RFC 6238) ] ───────> [ Passkeys (FIDO2 / WebAuthn) ]
   • Credential stuffing   • SIM swapping            • Shared secret seed         • Origin-bound public key crypto
   • Database breaches     • SS7 interception        • Vulnerable to AiTM         • Mathematically phishing-proof
   • Weak human entropy    • Social engineering        reverse-proxy phishing     • Hardware secure enclave / synced

Passwords and Modern Cryptographic Hashing

Despite the growth of passwordless solutions, password-based authentication remains widespread. When storing passwords, general-purpose cryptographic hash functions such as MD5, SHA-1, SHA-256, and SHA-512 are dangerous anti-patterns. These algorithms were designed for fast throughput in data integrity verification; modern GPUs and application-specific integrated circuits (ASICs) can compute billions of SHA-256 hashes per second, making offline dictionary attacks trivial.

Production password storage requires memory-hard, computationally adjustable key derivation functions (KDFs):

  1. Argon2id: The recommended default (winner of the Password Hashing Competition, RFC 9106). Argon2id is a hybrid variant combining Argon2d (resistant to GPU cracking via data-dependent memory access) and Argon2i (resistant to side-channel timing attacks via data-independent memory access).
  2. bcrypt: A battle-tested Blowfish-based algorithm with an exponential cost parameter (2cost2^{\text{cost}} iterations). It remains widely supported, though architects must account for its strict 72-byte input truncation limit (inputs longer than 72 bytes are silently truncated unless pre-hashed with a secure digest).
  3. scrypt: A memory-hard key derivation function (RFC 7914) that enforces significant RAM usage alongside CPU iterations, increasing the hardware manufacturing cost for custom cracking ASICs.

NIST SP 800-63B Guidelines

The National Institute of Standards and Technology (NIST) Special Publication 800-63B (Digital Identity Guidelines: Authentication and Lifecycle Management) introduced vital revisions that overturn decades of legacy password policies:

  • Prohibition of arbitrary composition rules: Requiring specific mixes of uppercase, lowercase, numbers, and special symbols encourages predictable human substitutions (e.g., swapping E for 3 or appending !1), reducing effective entropy.
  • Elimination of periodic forced expiration: Mandating 60- or 90-day password rotations without evidence of compromise leads users to make minor, predictable mutations to existing passwords rather than generating strong credentials.
  • Breached credential screening: Systems must check user-submitted passwords against lists of known breached credentials during registration and password updates. In production, this is accomplished using the k-Anonymity model (such as the Have I Been Pwned API), where the client queries using only the first 5 hexadecimal characters of the SHA-1 hash of the password, preventing third-party exposure of the full hash.
  • Support for long passphrases: Systems should support password lengths of at least 64 characters to accommodate memorable passphrases with high mathematical entropy.

The MFA Vulnerability Spectrum

Multi-factor authentication introduces additional authentication factors: something you know (password/PIN), something you have (hardware token/phone), and something you are (biometrics). However, not all MFA mechanisms provide equal security:

1. SMS and Voice OTP

Delivering one-time passwords via SMS or voice telephone calls relies on public telecommunications infrastructure that was never designed as a secure identity layer. Primary failure modes include:

  • SIM-swapping: Attackers use social engineering or compromised carrier staff to port the victim's phone number to an attacker-controlled SIM card.
  • SS7 Interception: Flaws in the Signaling System No. 7 (SS7) routing protocol permit nation-state actors and sophisticated adversaries to intercept SMS messages in transit.
  • Social Engineering: Users routinely forward OTP codes to attackers posing as support agents.

NIST SP 800-63B classifies SMS out-of-band authenticators as restricted, discouraging their use in high-risk environments.

2. Time-Based One-Time Passwords (TOTP, RFC 6238)

TOTP generates an algorithmic 6-digit code based on an HMAC hash of a shared symmetric secret seed (KK) and the current Unix time divided into 30-second windows (T=⌊UnixTime30⌋T = \lfloor \frac{\text{UnixTime}}{30} \rfloor):

TOTP=Truncate(HMAC-SHA-1(K,T))(mod106)\text{TOTP} = \text{Truncate}(\text{HMAC-SHA-1}(K, T)) \pmod{10^6}

TOTP eliminates SIM swapping and telecommunications interception because the seed never leaves the user's authenticator app. However, TOTP is fundamentally vulnerable to Adversary-in-the-Middle (AiTM) reverse-proxy phishing.

Using automated open-source proxy toolkits such as Evilginx or Modlishka, an attacker hosts a lookalike domain (e.g., login.company-security.com). The proxy transparently forwards requests to the legitimate application, prompts the user for their password and TOTP code, submits them to the real service, and captures the resulting authenticated session cookie. The TOTP code is valid for 30 seconds—more than enough time for the automated proxy to establish a session.

3. Passkeys & WebAuthn / FIDO2 (W3C Standard)

Web Authentication (WebAuthn), an integral component of the FIDO2 framework developed by the FIDO Alliance and the W3C, provides cryptographic, origin-bound authentication that is mathematically resistant to AiTM phishing.

WebAuthn replaces shared secrets with asymmetric public-key cryptography (typically ECDSA on the P-256 curve or Ed25519):

  1. During registration, the authenticator (e.g., Touch ID, Windows Hello, or a YubiKey) generates a unique asymmetric key pair inside a secure hardware enclave.
  2. The private key never leaves the secure hardware.
  3. The public key is sent to the Relying Party (your server) and stored alongside a credential identifier.
  4. During authentication, the server issues a cryptographic challenge. The authenticator signs the challenge together with client metadata, including the exact browser origin (the Relying Party ID / RP ID).
Why WebAuthn Defeats AiTM Phishing:

User Browser                       Phishing Proxy (Evilginx)                 Authentic Server
(origin: evil-login.com)           (evil-login.com)                          (origin: openstair.com)
     │                                    │                                          │
     │ 1. User visits phishing site       │                                          │
     │───────────────────────────────────>│                                          │
     │                                    │ 2. Proxy requests challenge              │
     │                                    │─────────────────────────────────────────>│
     │                                    │                                          │
     │                                    │ 3. Server issues challenge for openstair.com
     │                                    │<─────────────────────────────────────────│
     │ 4. Proxy relays challenge          │                                          │
     │<───────────────────────────────────│                                          │
     │                                    │                                          │
     │ [Authenticator checks browser origin]                                         │
     │ Origin is "evil-login.com"!        │                                          │
     │ Keys for "openstair.com" are       │                                          │
     │ cryptographically LOCKED.          │                                          │
     │ Signature cannot be generated!     │                                          │
     │ ❌ Authentication Fails            │                                          │

Because the browser's internal WebAuthn API extracts the origin directly from the active window context, it cannot be spoofed by an HTML document or a reverse proxy. If an attacker directs a user to evil-login.com, the browser passes origin: "https://evil-login.com" to the authenticator. The authenticator will not locate a credential registered for evil-login.com, or if it creates an assertion, the client data hash will sign the phishing origin. When the proxy forwards that assertion to the real server, the server detects the origin mismatch and rejects the request.

Synced Passkeys vs. Hardware Security Keys

Passkeys are deployed across two primary form factors:

  • Multi-device synced passkeys: Managed by operating system vendors (Apple iCloud Keychain, Google Password Manager, 1Password). Private keys are end-to-end encrypted and synchronized across the user's devices. This eliminates the risk of account lockout due to hardware loss, making passkeys viable for mass-consumer applications.
  • Hardware security keys (FIDO2 tokens): Dedicated physical devices (e.g., YubiKey, Titan Security Key). Private keys are marked as non-exportable and bound to a tamper-resistant microcontroller. They represent the highest security tier for enterprise infrastructure, privileged access management, and high-value accounts.

Comparative Evaluation of Authentication Mechanisms

| Credential Mechanism | Phishing Resistance | Revocation Latency | Client Footprint | Primary Vulnerability Vectors | | :--- | :--- | :--- | :--- | :--- | | Password + SMS OTP | None | Immediate (DB update) | Minimal | SIM swapping, SS7 interception, social engineering, credential stuffing. | | Password + App TOTP | None (Vulnerable to AiTM) | Immediate (DB update) | Low (Authenticator app) | AiTM reverse-proxy interception (Evilginx), credential stuffing. | | Magic Link (Email) | Low (Vulnerable to email hijack) | Depends on token TTL | Minimal (Email client) | Email account takeover, delayed email delivery, link pre-fetching. | | Passkeys (Synced FIDO2) | High (Cryptographic origin bound) | Immediate (Credential deletion) | Native OS / Browser | Device ecosystem account takeover, malware with local device access. | | Hardware FIDO2 Key | Maximum (Hardware-bound origin) | Immediate (Credential deletion) | Physical USB/NFC token | Physical device theft (mitigated by mandatory biometric or PIN). |


2. Modern Session Topologies

Once an identity assertion has been validated, the backend must maintain session continuity. The choice of session topology dictates the system's horizontal scalability, operational complexity, network latency, and token revocation responsiveness.

Session Topologies:

A. Stateful Server-Side Sessions:
   Client Cookie (Opaque Session ID) ──> API Gateway ──> Redis / DB Session Store (1 lookup / req)

B. Pure Stateless Tokens (Self-Contained JWT):
   Client Header (JWT Bearer) ───────> Microservice (Verifies RS256/EdDSA signature locally)
                                                      [Revocation problem: Token valid until TTL]

C. Hybrid Dual-Token Architecture (OAuth 2.1 Pattern):
   Client Memory: Short-Lived Access Token (5-15 min) ──> Resource APIs (Stateless validation)
   HttpOnly Cookie: Rotating Refresh Token (7-30 days)  ──> Auth Server (/refresh with rotation check)

1. Stateful Server-Side Sessions

In a stateful model, the authentication service generates a cryptographically secure random string (e.g., 256 bits from a CSPRNG) as a session ID. This identifier is transmitted to the client in an HttpOnly, Secure, SameSite=Strict or Lax cookie. The application maps this ID to the user record in a fast, in-memory datastore such as Redis or a distributed relational database.

  • Advantages: Immediate session revocation. When a user logs out, changes their password, or triggers an incident response event, the server deletes the session key from Redis. All subsequent requests fail immediately.
  • Disadvantages: Every API request requires a remote round-trip to the session datastore. In globally distributed multi-region deployments, cross-region replication latency can degrade response times or require complex regional session pinning.

Quantitative Memory Sizing for Stateful Sessions

When sizing a Redis cluster for stateful sessions, teams must calculate raw buffer allocation versus real memory footprint.

Consider an application supporting 1,000,000 concurrent active sessions:

  • Session Payload: User ID (UUID, 36 bytes), Created At timestamp (8 bytes), Last Active timestamp (8 bytes), Scope/Role flags (64 bytes), Device metadata/IP string (128 bytes) ≈244\approx 244 bytes.
  • Redis Internal Overhead: Key overhead, jemalloc allocation padding, dict entry pointers, and TTL expiry timers average roughly 250–350 bytes per key.
  • Total per session: ≈600 bytes−1 KB\approx 600\text{ bytes} - 1\text{ KB}.
  • Cluster RAM calculation: 1,000,000 sessions×1 KB≈1 GB of raw Redis memory.1,000,000\text{ sessions} \times 1\text{ KB} \approx 1\text{ GB of raw Redis memory}.

While 1 GB is operationally modest, high-throughput clusters must account for Redis replication buffers, snapshotting memory (copy-on-write overhead during BGSAVE), and peak command traffic. Profiling connection pool latency under high concurrent load remains critical.


2. Pure Stateless Tokens (JWT / PASETO)

JSON Web Tokens (RFC 7519) and Platform-Agnostic Security Tokens (PASETO) allow services to verify identity without querying a central database. The identity provider signs a JSON payload containing user claims (sub, iss, exp, roles) using an asymmetric private key (RS256, ES256, or Ed25519). Downstream microservices verify the signature using the identity provider's cached public key.

  • Advantages: Zero database lookups on the request path. Downstream services validate tokens in microsecond CPU time, enabling seamless horizontal autoscaling across multiple geographic regions.
  • Disadvantages: The revocation problem. Because a JWT is self-contained and stateless, it cannot be revoked before its expiration timestamp (exp) without introducing state. If an access token is compromised or a user's permissions are revoked, the attacker retains access until the token expires.

Attempting to fix this by maintaining a distributed blacklist/denylist of revoked token IDs (jti) in Redis re-introduces a shared stateful lookup on every request, negating the architectural benefits of stateless tokens while retaining the security risks of token exposure.


3. Hybrid Dual-Token Architecture (The OAuth 2.1 Pattern)

The industry standard for modern web and mobile applications is the hybrid dual-token architecture, codified in the OAuth 2.1 draft and OAuth 2.0 Security Best Current Practice (BCP, RFC 9700):

  1. Access Token:
    • Short lifespan: typically 5 to 15 minutes.
    • Kept in browser JavaScript memory (or a short-lived cookie). Never stored in localStorage or sessionStorage where it is vulnerable to cross-site scripting (XSS) exfiltration.
    • Used as a bearer token to authenticate API calls against resource servers.
    • Statelessly verified by resource servers via asymmetric public keys.
  2. Refresh Token:
    • Longer lifespan: 7 to 30 days with sliding expiration.
    • Stored strictly in an HttpOnly, Secure, SameSite=Strict cookie, with the cookie path restricted to the token refresh endpoint (e.g., Path=/api/auth/refresh).
    • Stored in a database alongside a token family identifier.

Refresh Token Rotation with Reuse Detection

OAuth 2.1 mandates Refresh Token Rotation (RTR) for public and single-page clients:

  • Every time a refresh token is used to issue a new access token, the auth server invalidates the old refresh token and issues a new refresh token.
  • If a client presents a refresh token that has already been used, the authentication server treats this as evidence of token theft.
  • The server immediately revokes the entire token family, terminating all active sessions for that user across all devices.
Sequence: Refresh Token Rotation & Breach Detection

Client                              Auth Server                         Database
  │                                      │                                 │
  │ 1. POST /api/auth/refresh            │                                 │
  │    (Refresh Token RT-1, Family F-1)  │                                 │
  │─────────────────────────────────────>│                                 │
  │                                      │ 2. Check RT-1 status            │
  │                                      │    RT-1 is valid & unused       │
  │                                      │ 3. Mark RT-1 as USED            │
  │                                      │    Issue RT-2 (Family F-1)      │
  │                                      │────────────────────────────────>│
  │ 4. Return new Access Token + RT-2    │                                 │
  │<─────────────────────────────────────│                                 │
  │                                      │                                 │
══════════════════ [ COMPROMISE SCENARIO: ATTACKER PRESENTS STOLEN RT-1 ] ══════════════════
Attacker                            Auth Server                         Database
  │                                      │                                 │
  │ 5. POST /api/auth/refresh            │                                 │
  │    (Stolen Refresh Token RT-1)       │                                 │
  │─────────────────────────────────────>│                                 │
  │                                      │ 6. Lookup RT-1 status           │
  │                                      │────────────────────────────────>│
  │                                      │ 7. STATUS: ALREADY USED!        │
  │                                      │    🚨 POTENTIAL TOKEN THEFT     │
  │                                      │ 8. REVOKE ALL TOKENS IN F-1     │
  │                                      │────────────────────────────────>│
  │ 9. HTTP 401 Unauthorized             │                                 │
  │<─────────────────────────────────────│                                 │

3. Identity Federation & Enterprise SSO

Modern B2B SaaS and consumer applications rarely act as isolated identity islands. They must integrate with external Identity Providers (IdPs) via federated authentication standards.

OpenID Connect (OIDC) & OAuth 2.1

OpenID Connect is an identity layer built on top of the OAuth 2.0 authorization framework. While OAuth 2.0 was designed strictly for delegated resource authorization, OIDC standardizes identity assertions.

Key architectural concepts:

  • Discovery Document (/.well-known/openid-configuration): A standardized metadata endpoint where an IdP publishes its JSON Web Key Set (JWKS) URI, authorization endpoint, token endpoint, supported scopes, and signing algorithms. Relying parties automatically retrieve and rotate public signing keys without manual intervention.
  • ID Token vs. Access Token:
    • id_token: A signed JWT consumed by the client application to display user profile details (name, email, sub). It must never be sent to resource servers as an API authorization credential.
    • access_token: An opaque or JWT token intended for the resource server (API) to authorize data access. The client should treat it as an opaque credential.
  • Proof Key for Code Exchange (PKCE, RFC 7636): Originally created for native mobile apps, PKCE is now mandatory for all OAuth 2.1 clients, including server-side web apps and single-page apps. PKCE eliminates the authorization code interception attack by dynamically binding the authorization request to the token exchange request using a cryptographic secret (code_verifier) and its SHA-256 hash (code_challenge). This deprecates the legacy, insecure OAuth 2.0 Implicit Grant flow (response_type=token).

Enterprise SSO: SAML 2.0 vs. OpenID Connect

Enterprise customers requiring single sign-on (SSO) typically enforce centralized identity through corporate IdPs such as Microsoft Entra ID (Azure AD), Okta, or Ping Identity. Backend systems must support both SAML 2.0 and OIDC.

Enterprise SSO Architecture Comparison:

Feature                   SAML 2.0 (Security Assertion Markup Lang)     OpenID Connect (OIDC)
─────────────────────────────────────────────────────────────────────────────────────────────
Data Serialization        XML with XMLDSig                              JSON with JWS / JWE
Transport Mechanism       HTTP POST / Redirect with URL-encoded XML     REST endpoints with JSON payloads
Cryptographic Surface     Complex XML Canonicalization (C14N)           Standardized JWT / JWKS verification
Primary Vulnerabilities   XML External Entity (XXE), Signature Wrapping Token leakage, improper signature checks
Mobile & SPA Support      Poor (relies heavily on browser redirects)    Native (PKCE, modular token endpoints)
Enterprise Adoption       Ubiquitous in legacy & government sectors     Standard in modern cloud architectures

Engineering Trade-offs for B2B SaaS

  • SAML 2.0: The XML ecosystem introduces parsing vulnerabilities, including XML Signature Wrapping (XSW) attacks, where attackers inject forged assertions outside the signature evaluation boundary. When implementing SAML, backend engineers should rely on hardened, actively maintained libraries rather than attempting manual XML manipulation.
  • OIDC: Lighter bandwidth footprint, straightforward token decoding in microservices, and native alignment with modern TypeScript and mobile development.
  • Recommendation: For new enterprise SaaS platforms, provide OIDC as the primary SSO protocol, offering SAML 2.0 fallback compatibility for legacy enterprise directories.

4. Zero Trust & Continuous Contextual Security

Perimeter-based network security—assuming that any request originating inside a VPN or corporate subnet is trustworthy—is obsolete. The Zero Trust security model operates on the principle: "Never trust, always verify."

Continuous Adaptive Risk and Trust Assessment (CARTA)

Authentication cannot remain a binary, point-in-time check performed once at the beginning of a session. Under the Gartner CARTA framework, the backend continuously assesses risk throughout the session lifecycle based on contextual telemetry:

  • Device Posture: Is the client running on a managed corporate device with full disk encryption and an active endpoint detection agent?
  • Network & Location Telemetry: Has the client's IP address shifted from London to Tokyo in under 30 minutes (impossible travel velocity)? Is the traffic originating from a known Tor exit node or commercial VPN proxy?
  • Behavioral Anomalies: Is the user suddenly attempting to export thousands of database records via an API endpoint they have never previously accessed?

Step-Up Authentication

When an authenticated user attempts a high-risk or sensitive operation—such as modifying bank payout details, updating MFA configurations, generating master API keys, or revoking team members—the application should enforce Step-Up Authentication.

Rather than terminating the entire session, the backend checks the recency and assurance level of the user's authentication context:

  • Standard OIDC claims: auth_time (Unix timestamp of initial authentication), amr (Authentication Methods References, indicating whether a password, OTP, or passkey was used), and acr (Authentication Context Class Reference).
  • If the operation requires high assurance and the existing credential was issued too long ago (e.g., more than 5 minutes prior) or used a low-assurance method, the API returns an HTTP 403 Forbidden with a step-up challenge header (e.g., WWW-Authenticate: StepUp max_age=300, amr="fido2").
  • The client prompts for a biometric passkey assertion, and upon successful validation, receives an elevated, short-lived step-up token permitting the sensitive action.
Step-Up Authentication Flow:

Client                                API Gateway / Service                 Identity Service
  │                                             │                                  │
  │ 1. POST /api/billing/update-bank-account    │                                  │
  │    (Standard Access Token, age = 45m)       │                                  │
  │────────────────────────────────────────────>│                                  │
  │                                             │ 2. Inspect token:                │
  │                                             │    Operation requires:           │
  │                                             │    - auth_time < 5m              │
  │                                             │    - AMR includes 'passkey'      │
  │                                             │    Validation failed: STALE!     │
  │ 3. HTTP 403 Forbidden                       │                                  │
  │    Header: WWW-Authenticate: StepUp         │                                  │
  │    max_age=300, required_amr="passkey"      │                                  │
  │<────────────────────────────────────────────│                                  │
  │                                                                                │
  │ 4. Trigger WebAuthn Biometric Prompt                                           │
  │ 5. Authenticate with Identity Service                                          │
  │───────────────────────────────────────────────────────────────────────────────>│
  │ 6. Issue Step-Up Token (amr: ["passkey"], auth_time: now)                      │
  │<───────────────────────────────────────────────────────────────────────────────│
  │                                             │                                  │
  │ 7. Retry POST /api/billing/update-bank      │                                  │
  │    (Header: X-Step-Up-Token: <token>)       │                                  │
  │────────────────────────────────────────────>│                                  │
  │                                             │ 8. Token verified! Executing.    │
  │ 9. HTTP 200 OK                              │                                  │
  │<────────────────────────────────────────────│                                  │

Hardware Device Attestation

For native mobile applications (iOS and Android), credentials can be intercepted if the client environment is rooted, jailbroken, running inside an emulator, or tampered with by an instrumentation framework like Frida.

Production mobile backends leverage hardware attestation APIs:

  • Apple App Attest & DeviceCheck: Uses the Apple Secure Enclave to verify that the client app binary is authentic, unmodified, signed by your team's developer certificate, and executing on genuine Apple hardware.
  • Google Play Integrity API: Validates that requests originate from an unmodified app installed from Google Play, running on a genuine, CTS-certified Android device with an uncompromised bootloader.

The mobile app includes an attestation assertion with its initial authentication handshake, permitting the backend to deny service to compromised or instrumented environments.


5. Concrete TypeScript Implementation

To demonstrate these principles in practice, the following implementations provide production-grade, self-contained TypeScript components using standard Node.js cryptographic primitives (node:crypto).

1. WebAuthn / FIDO2 Challenge & Assertion Verification

The following module implements challenge generation and assertion verification for WebAuthn authentication (navigator.credentials.get()). It validates the client data JSON, matches the challenge, checks the Relying Party ID hash, verifies the user presence and user verification flags, and validates the cryptographic signature against the user's registered public key.

import crypto from "node:crypto";

export interface StoredWebAuthnCredential {
  credentialId: string; // Base64URL encoded ID
  publicKeyPem: string; // SPKI formatted public key in PEM format
  signCount: number;    // Stored signature counter to prevent replay
  userId: string;
}

export interface WebAuthnAssertionPayload {
  id: string;
  rawId: string;
  response: {
    clientDataJSON: string;      // Base64URL encoded JSON
    authenticatorData: string;   // Base64URL encoded binary buffer
    signature: string;           // Base64URL encoded DER signature
    userHandle?: string;
  };
}

export interface VerificationOptions {
  expectedChallenge: string;
  expectedOrigin: string;
  expectedRpId: string;
  requireUserVerification?: boolean;
}

export class WebAuthnVerifier {
  /**
   * Generates a cryptographically secure random challenge for WebAuthn ceremony.
   */
  public static generateChallenge(byteLength = 32): string {
    return crypto.randomBytes(byteLength).toString("base64url");
  }

  /**
   * Verifies an authentication assertion against a registered public key.
   */
  public static verifyAuthenticationAssertion(
    assertion: WebAuthnAssertionPayload,
    storedCredential: StoredWebAuthnCredential,
    options: VerificationOptions
  ): { verified: boolean; newSignCount: number; error?: string } {
    try {
      // 1. Parse and validate clientDataJSON
      const clientDataBuffer = Buffer.from(assertion.response.clientDataJSON, "base64url");
      const clientDataString = clientDataBuffer.toString("utf8");
      const clientData = JSON.parse(clientDataString);

      // Verify ceremony type
      if (clientData.type !== "webauthn.get") {
        return { verified: false, newSignCount: storedCredential.signCount, error: "Invalid ceremony type" };
      }

      // Verify challenge equality (constant-time comparison)
      const challengeBuffer = Buffer.from(clientData.challenge);
      const expectedChallengeBuffer = Buffer.from(options.expectedChallenge);
      if (
        challengeBuffer.length !== expectedChallengeBuffer.length ||
        !crypto.timingSafeEqual(challengeBuffer, expectedChallengeBuffer)
      ) {
        return { verified: false, newSignCount: storedCredential.signCount, error: "Challenge mismatch or expired" };
      }

      // Verify origin
      if (clientData.origin !== options.expectedOrigin) {
        return { verified: false, newSignCount: storedCredential.signCount, error: "Origin mismatch" };
      }

      // 2. Parse authenticatorData buffer
      const authData = Buffer.from(assertion.response.authenticatorData, "base64url");
      if (authData.length < 37) {
        return { verified: false, newSignCount: storedCredential.signCount, error: "Authenticator data too short" };
      }

      // Verify RP ID hash (Bytes 0..31)
      const rpIdHash = authData.subarray(0, 32);
      const expectedRpIdHash = crypto.createHash("sha256").update(options.expectedRpId).digest();
      if (!crypto.timingSafeEqual(rpIdHash, expectedRpIdHash)) {
        return { verified: false, newSignCount: storedCredential.signCount, error: "RP ID hash mismatch" };
      }

      // Verify Flags (Byte 32)
      const flags = authData[32];
      const userPresent = (flags & 0x01) !== 0; // Bit 0: User Present (UP)
      const userVerified = (flags & 0x04) !== 0; // Bit 2: User Verified (UV)

      if (!userPresent) {
        return { verified: false, newSignCount: storedCredential.signCount, error: "User was not present" };
      }

      if (options.requireUserVerification && !userVerified) {
        return { verified: false, newSignCount: storedCredential.signCount, error: "User verification required but not performed" };
      }

      // Verify Signature Counter (Bytes 33..36, Big Endian 32-bit unsigned integer)
      const incomingSignCount = authData.readUInt32BE(33);

      // Clone detection: If either counter is non-zero, incoming counter must be greater
      if (incomingSignCount > 0 || storedCredential.signCount > 0) {
        if (incomingSignCount <= storedCredential.signCount) {
          return {
            verified: false,
            newSignCount: storedCredential.signCount,
            error: "Authenticator clone detected: sign count did not increment",
          };
        }
      }

      // 3. Compute verification hash: SHA-256(clientDataJSON)
      const clientDataHash = crypto.createHash("sha256").update(clientDataBuffer).digest();

      // The signature is formed over the concatenation of authData and clientDataHash
      const signaturePayload = Buffer.concat([authData, clientDataHash]);
      const signatureBuffer = Buffer.from(assertion.response.signature, "base64url");

      // 4. Verify signature using stored public key
      const verify = crypto.createVerify("SHA256");
      verify.update(signaturePayload);
      const isValid = verify.verify(storedCredential.publicKeyPem, signatureBuffer);

      if (!isValid) {
        return { verified: false, newSignCount: storedCredential.signCount, error: "Cryptographic signature invalid" };
      }

      return { verified: true, newSignCount: incomingSignCount };
    } catch (err) {
      return {
        verified: false,
        newSignCount: storedCredential.signCount,
        error: err instanceof Error ? err.message : "Assertion verification failed",
      };
    }
  }
}

2. Step-Up Authentication Middleware

The following TypeScript middleware enforces step-up authentication on sensitive routes. It evaluates the authentication recency and method assurance level (amr) from an authenticated context, returning an HTTP 403 Forbidden response with standard challenge headers if the criteria are unmet.

export interface RequestAuthContext {
  userId: string;
  sessionId: string;
  authTimeSeconds: number; // Unix timestamp when identity was verified
  amr: string[];           // Authentication Method References (e.g. ['pwd', 'fido2'])
  acr?: string;            // Authentication Context Class Reference
}

export interface StepUpConfig {
  maxAgeSeconds: number;    // Maximum permitted age of credential
  requiredMethod?: string;  // Required factor (e.g. 'fido2' or 'passkey')
  actionName: string;       // Descriptive identifier for the challenged action
}

export interface MiddlewareResult {
  allowed: boolean;
  statusCode?: number;
  headers?: Record<string, string>;
  errorBody?: {
    code: string;
    message: string;
    action: string;
    maxAge: number;
    requiredMethod?: string;
  };
}

/**
 * Enforces step-up authentication checks on high-risk operations.
 */
export function evaluateStepUpRequirement(
  auth: RequestAuthContext | undefined,
  config: StepUpConfig,
  currentTimestampSeconds = Math.floor(Date.now() / 1000)
): MiddlewareResult {
  // 1. Verify that base session exists
  if (!auth) {
    return {
      allowed: false,
      statusCode: 401,
      headers: {
        "WWW-Authenticate": 'Bearer error="invalid_token", error_description="Authentication required"',
      },
      errorBody: {
        code: "UNAUTHENTICATED",
        message: "Active session required to perform this action.",
        action: config.actionName,
        maxAge: config.maxAgeSeconds,
      },
    };
  }

  // 2. Evaluate authentication recency
  const credentialAge = currentTimestampSeconds - auth.authTimeSeconds;
  const isStale = credentialAge > config.maxAgeSeconds;

  // 3. Evaluate assurance method (AMR)
  const hasRequiredMethod = config.requiredMethod
    ? auth.amr.includes(config.requiredMethod)
    : true;

  if (isStale || !hasRequiredMethod) {
    const reason = isStale
      ? `Authentication credentials expired for sensitive action (${credentialAge}s old, max ${config.maxAgeSeconds}s)`
      : `Action requires authentication method: ${config.requiredMethod}`;

    return {
      allowed: false,
      statusCode: 403,
      headers: {
        "WWW-Authenticate": `StepUp realm="${config.actionName}", max_age=${config.maxAgeSeconds}${
          config.requiredMethod ? `, required_amr="${config.requiredMethod}"` : ""
        }`,
      },
      errorBody: {
        code: "STEP_UP_REQUIRED",
        message: reason,
        action: config.actionName,
        maxAge: config.maxAgeSeconds,
        requiredMethod: config.requiredMethod,
      },
    };
  }

  // Authentication meets freshness and assurance criteria
  return { allowed: true };
}

6. Common Architectural Anti-Patterns

Even well-intentioned authentication designs can introduce critical security liabilities. When reviewing authentication codebases, watch for these common architectural anti-patterns:

1. Storing Access and Refresh Tokens in LocalStorage

A common shortcut in frontend tutorials is storing JWTs in browser window.localStorage or window.sessionStorage.

  • The Risk: Any third-party JavaScript running on the page—including analytics widgets, customer chat plugins, or dependencies compromised via supply-chain attacks—can access localStorage via XSS and silently exfiltrate long-lived credentials.
  • The Remedy: Store refresh tokens exclusively in HttpOnly, Secure, SameSite=Strict cookies. Keep short-lived access tokens strictly in memory within the client application.

2. Stateless JWTs for Long-Lived User Sessions

Using self-contained JWTs with 30-day expiration timestamps to avoid running a session database.

  • The Risk: If an employee leaves a company, their permissions are altered, or their laptop is stolen, there is no way to terminate access immediately without revoking the entire public key pair and logging out all active users.
  • The Remedy: Limit stateless access tokens to short lifetimes (5–15 minutes), and validate session status during refresh token exchange.

3. Accepting the alg: "none" Algorithm

In early implementations of JSON Web Signature (JWS), developers often configured libraries to accept unauthenticated tokens where the header declared "alg": "none".

  • The Risk: An attacker can modify claims (such as elevating their role to admin), strip the signature, and submit the forged token to a vulnerable endpoint.
  • The Remedy: Always explicitly whitelist approved cryptographic algorithms (e.g., algorithms: ['RS256', 'ES256']) in the verification configuration. Never trust the algorithm specified in the unverified token header.

4. Relying on SMS OTP for Critical System Operations

Treating SMS delivery as a secure verification factor for wire transfers, password resets, or API key generation.

  • The Risk: Vulnerability to SIM swapping and carrier social engineering.
  • The Remedy: Require FIDO2/WebAuthn passkeys or at minimum TOTP authenticator apps for high-value transactions.

5. Conflating Authentication with Authorization

Using identity assertions (e.g., verifying that a user is logged in as user_123) to infer permissions (assuming user_123 can access any resource belonging to their organization).

  • The Risk: Broken Object Level Authorization (BOLA / IDOR), consistently ranked as the most prevalent vulnerability in modern APIs.
  • The Remedy: Keep authentication (verifying identity claims) and authorization (evaluating fine-grained policies like RBAC or ABAC) strictly decoupled. For more details on this separation, review our guide on Authentication vs Authorization: Understanding the Difference.

7. A Practical Production Verification Checklist

Before deploying an authentication subsystem to production, verify each requirement against this architectural checklist:

Credential Storage & Password Hashing

  • [ ] Passwords hashed using Argon2id (preferred), bcrypt, or scrypt. No SHA-256, MD5, or plain hashing.
  • [ ] NIST SP 800-63B compliance: No arbitrary composition rules, no periodic forced expiration without compromise.
  • [ ] New passwords checked against a breached credential database via k-Anonymity lookup.
  • [ ] Password field supports inputs up to at least 64 characters without silent truncation.

Multi-Factor & Phishing Resistance

  • [ ] FIDO2 / WebAuthn passkey support implemented as the primary phishing-resistant authentication method.
  • [ ] Relying Party ID (RP ID) and origin checks cryptographically validated on the backend.
  • [ ] Signature counter increment verified during WebAuthn assertion to detect authenticator cloning.
  • [ ] SMS OTP deprecated or restricted to low-risk recovery fallbacks with rate-limiting and delay penalties.

Session Topologies & Token Security

  • [ ] Refresh tokens stored in HttpOnly, Secure, SameSite=Strict cookies with path restricted to /api/auth/refresh.
  • [ ] Refresh Token Rotation (RTR) enforced: Used tokens are immediately invalidated.
  • [ ] Token reuse detection active: Presenting an already-used refresh token revokes the entire token family.
  • [ ] Access token lifespan restricted to 5–15 minutes.
  • [ ] Zero sensitive tokens stored in localStorage or sessionStorage.

Federation & Enterprise SSO

  • [ ] OIDC Discovery (/.well-known/openid-configuration) used for automated JWKS public key rotation.
  • [ ] Proof Key for Code Exchange (PKCE) enforced on all OAuth authorization code flows.
  • [ ] Implicit Grant flow (response_type=token) completely disabled.
  • [ ] SAML assertions validated against XML External Entity (XXE) and signature wrapping attacks.

Zero Trust & Continuous Security

  • [ ] Step-up authentication enforced on sensitive endpoints (modifying MFA, changing payout accounts, revoking users).
  • [ ] Continuous telemetry (IP velocity, unusual user agent, behavioral heuristics) monitored for session anomalies.
  • [ ] Mobile APIs protected via Apple App Attest / DeviceCheck or Google Play Integrity.
  • [ ] Distributed rate limiting active on login, registration, password reset, and token refresh endpoints.

8. Conclusion

Designing authentication for modern distributed applications is an ongoing architectural responsibility rather than a static setup task. As attack vectors evolve toward automated reverse-proxy phishing and session hijacking, legacy security assumptions no longer suffice.

Resilient identity architectures are built on verified foundations:

  1. Asymmetric origin-bound credentials (WebAuthn / Passkeys) eliminate the vulnerability surface of shared secrets and defeat Adversary-in-the-Middle attacks.
  2. Hybrid session topologies combine the low latency of short-lived access tokens with the instantaneous revocation and breach-containment guarantees of rotating refresh tokens.
  3. Continuous Zero Trust verification and step-up policies ensure that trust is evaluated contextually at the moment of risk, rather than granted unconditionally at login.

By decoupling identity federation from fine-grained authorization, enforcing strict cryptographic discipline, and designing failure modes that make security compromises observable and containable, engineering teams build systems that protect user trust without sacrificing operational scalability.


Related reading:

Related articles

Illustration of logs, metrics, traces, and alerts feeding an observability dashboard
Backend

Backend Observability: Logs, Metrics, Traces, and Alerts

September 23, 2025 · 18 min read

A comprehensive engineering guide to backend observability: RED and USE metrics, structured logging, OpenTelemetry distributed tracing, tail sampling, and SRE error budget alerting.

BackendAPIsSoftware EngineeringArchitecture