Skip to content

DOMAIN:SECURITY:AUTHENTICATION_PATTERNS

OWNER: hugo
ALSO_USED_BY: victoria, pol, piotr, koen, eric
UPDATED: 2026-03-24
SCOPE: all auth implementations — GE internal and client projects
GE_INTERNAL: admin-ui uses WebAuthn only
CLIENT_PROJECTS: NextAuth + Keycloak


CORE_PRINCIPLE

RULE: authentication proves WHO you are — authorization proves WHAT you can do
RULE: never roll your own auth primitives — use proven libraries and standards
RULE: defense in depth — MFA is MANDATORY for admin functions
RULE: sessions are the primary attack surface — protect them as critical assets


WEBAUTHN_DEEP_DIVE

STANDARD: WebAuthn Level 2 (W3C Recommendation)
STANDARD: FIDO2 / CTAP2 (authenticator protocol)
GE_USAGE: admin-ui internal authentication (hardware keys and platform authenticators)

WEBAUTHN_CONCEPTS

term definition
Relying Party (RP) the website/service requesting authentication (admin-ui)
Authenticator device that creates/uses credentials (YubiKey, Touch ID, Windows Hello)
Credential public-private key pair bound to RP and user
Resident Key credential stored ON authenticator (discoverable, enables passwordless)
Non-resident Key credential ID stored by RP, authenticator holds private key
Attestation proof of authenticator type during registration
Assertion proof of credential possession during authentication

REGISTRATION_FLOW

1. Client requests challenge
   → POST /api/auth/register/challenge
   ← { challenge: Uint8Array, rp: { name, id }, user: { id, name, displayName },
       pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { alg: -257 }],
       authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' },
       attestation: 'none' }

2. Server stores challenge
   RULE: challenge MUST be stored in httpOnly cookie — NOT in response body
   RULE: challenge MUST be cryptographically random (>= 32 bytes)
   RULE: challenge MUST expire (5 minutes max)

3. Browser calls navigator.credentials.create()
   → authenticator prompts user (biometric, PIN, touch)
   → authenticator creates new key pair
   ← PublicKeyCredential { id, rawId, response: AuthenticatorAttestationResponse, type }

4. Client sends attestation to server
   → POST /api/auth/register/verify
   → body: { id, rawId, response: { clientDataJSON, attestationObject }, type }
   → cookie: challenge (httpOnly)

5. Server verifies:
   a. challenge matches stored challenge (from cookie)
   b. origin matches expected RP origin
   c. rpIdHash matches expected RP ID
   d. user verification flag set (if required)
   e. extract and store public key + credential ID

6. Server stores credential:
   → DB: { userId, credentialId, publicKey, counter, transports, createdAt }

AUTHENTICATION_FLOW

1. Client requests challenge
   → POST /api/auth/login/challenge
   ← { challenge: Uint8Array, rpId: 'admin.ge.internal',
       allowCredentials: [{ type: 'public-key', id: credentialId, transports }],
       userVerification: 'required', timeout: 60000 }

2. Server stores challenge in httpOnly cookie
   RULE: same as registration — cookie, not body

3. Browser calls navigator.credentials.get()
   → authenticator prompts user
   ← PublicKeyCredential { id, rawId, response: AuthenticatorAssertionResponse, type }

4. Client sends assertion to server
   → POST /api/auth/login/verify
   → body: { id, rawId, response: { authenticatorData, clientDataJSON, signature, userHandle } }
   → cookie: challenge (httpOnly)

5. Server verifies:
   a. credential ID exists in database for this user
   b. challenge matches stored challenge (from cookie)
   c. origin and rpIdHash correct
   d. user verification flag set
   e. signature valid against stored public key
   f. counter > stored counter (replay protection)
   g. update stored counter

6. Server creates session (httpOnly cookie)

GE_ADMIN_UI_WEBAUTHN_RULES

RULE: challenge MUST be stored in httpOnly cookie between /api/auth/challenge and /api/auth/verify
ANTI_PATTERN: storing challenge in body.challenge — was undefined and broke login entirely
FIX: set challenge as httpOnly, Secure, SameSite=Strict cookie

RULE: RP ID must match the domain exactly (no wildcards)
RULE: user verification REQUIRED (biometric/PIN, not just presence)
RULE: attestation 'none' is fine for internal use — 'direct' only if you need to verify authenticator type
RULE: support multiple credentials per user (backup key)

// GE admin-ui challenge endpoint pattern
export async function POST(req: Request) {
  const challenge = crypto.getRandomValues(new Uint8Array(32))

  // CORRECT: store in httpOnly cookie
  const response = NextResponse.json({
    challenge: base64url.encode(challenge),
    // ... other options
  })
  response.cookies.set('webauthn-challenge', base64url.encode(challenge), {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 300,  // 5 minutes
    path: '/api/auth',
  })
  return response
}

export async function POST(req: Request) {
  // CORRECT: read challenge from cookie
  const storedChallenge = req.cookies.get('webauthn-challenge')?.value
  if (!storedChallenge) throw new Error('Challenge expired or missing')

  // verify assertion against stored challenge
  // ...

  // clear challenge cookie after use
  response.cookies.delete('webauthn-challenge')
}

RESIDENT_KEYS (DISCOVERABLE CREDENTIALS)

RULE: use residentKey: 'preferred' — allows passwordless when authenticator supports it
NOTE: resident keys enable true passwordless — no username needed, authenticator offers credential list
NOTE: hardware key storage is limited (25-100 resident keys on YubiKey)
NOTE: platform authenticators (Touch ID, Windows Hello) have unlimited resident key storage

IF authenticator supports resident keys THEN:
  → user visits login page
  → browser auto-suggests available credentials
  → user selects credential + verifies (biometric/PIN)
  → fully passwordless flow

IF authenticator does NOT support resident keys THEN:
  → user enters username
  → server looks up credential IDs for user
  → sends allowCredentials list
  → user verifies with authenticator
  → passwordless but requires username

OAUTH2_AND_OIDC

STANDARD: OAuth 2.0 (RFC 6749, RFC 6750)
STANDARD: OpenID Connect Core 1.0
STANDARD: OAuth 2.1 (draft — consolidates best practices)
GE_USAGE: client projects use OIDC via Keycloak

OAUTH2_FLOWS

flow use case GE usage
Authorization Code + PKCE web apps, SPAs, mobile PRIMARY — all client projects
Client Credentials machine-to-machine service-to-service auth
Device Code TV/IoT devices unlikely in GE projects
Implicit DEPRECATED — do not use NEVER
Resource Owner Password DEPRECATED — do not use NEVER

RULE: ALWAYS use Authorization Code with PKCE — even for server-side apps
RULE: NEVER use Implicit flow — access token in URL fragment is insecure
RULE: NEVER use Resource Owner Password — sends credentials to client app

AUTHORIZATION_CODE_WITH_PKCE

1. Client generates code_verifier (random string, 43-128 chars)
2. Client computes code_challenge = SHA256(code_verifier), base64url-encoded

3. Client redirects user to authorization server:
   GET /auth?response_type=code&client_id=X&redirect_uri=Y
       &scope=openid+profile+email&state=RANDOM
       &code_challenge=HASH&code_challenge_method=S256

4. User authenticates with Keycloak
5. Keycloak redirects to client with authorization code:
   GET /callback?code=AUTH_CODE&state=RANDOM

6. Client verifies state matches (CSRF protection)
7. Client exchanges code for tokens:
   POST /token
   grant_type=authorization_code&code=AUTH_CODE
   &redirect_uri=Y&client_id=X&code_verifier=ORIGINAL_VERIFIER

8. Keycloak validates code_verifier against stored code_challenge
9. Returns: { access_token, id_token, refresh_token, expires_in }

OIDC_ID_TOKEN_VALIDATION

RULE: ALWAYS validate id_token — never trust blindly

VALIDATE:
  1. signature — verify JWT signature with Keycloak's public key (JWKS endpoint)
  2. iss — matches Keycloak issuer URL
  3. aud — contains your client_id
  4. exp — not expired
  5. iat — not issued too far in the past
  6. nonce — matches nonce sent in auth request (if used)
  7. at_hash — matches hash of access_token (if present)

TOKEN_TYPES

token purpose lifetime storage
access_token API authorization 5-15 minutes memory only (never localStorage)
id_token user identity claims 5-15 minutes memory only
refresh_token obtain new access_token hours-days httpOnly cookie or secure server-side

ANTI_PATTERN: storing tokens in localStorage — accessible via XSS
FIX: httpOnly cookies for refresh tokens, memory for access tokens
ANTI_PATTERN: long-lived access tokens (>15 minutes)
FIX: short access tokens + refresh token rotation


NEXTAUTH_PATTERNS

GE_USAGE: all client projects use NextAuth (Auth.js v5) with Keycloak provider

CONFIGURATION

// auth.ts — NextAuth configuration for GE client projects
import NextAuth from 'next-auth'
import Keycloak from 'next-auth/providers/keycloak'

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Keycloak({
      clientId: process.env.KEYCLOAK_CLIENT_ID!,
      clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
      issuer: process.env.KEYCLOAK_ISSUER!,  // https://auth.example.com/realms/client-realm
    }),
  ],
  session: {
    strategy: 'jwt',          // stateless — no session DB needed
    maxAge: 8 * 60 * 60,      // 8 hours
    updateAge: 60 * 60,       // refresh session every 1 hour
  },
  callbacks: {
    async jwt({ token, account }) {
      // persist Keycloak tokens on initial sign-in
      if (account) {
        token.accessToken = account.access_token
        token.refreshToken = account.refresh_token
        token.expiresAt = account.expires_at
        token.roles = account.realm_access?.roles ?? []
      }
      // refresh access token if expired
      if (Date.now() < (token.expiresAt as number) * 1000) {
        return token
      }
      return refreshAccessToken(token)
    },
    async session({ session, token }) {
      session.user.roles = token.roles as string[]
      session.accessToken = token.accessToken as string
      return session
    },
  },
  pages: {
    signIn: '/auth/login',
    error: '/auth/error',
  },
})

NEXTAUTH_SECURITY_RULES

RULE: NEXTAUTH_SECRET must be >= 32 characters, cryptographically random
RUN: openssl rand -base64 32 to generate
RULE: NEXTAUTH_URL must match production URL exactly
RULE: use JWT strategy for stateless sessions — database strategy for high-security apps

CHECK: is NEXTAUTH_SECRET set in production environment (not hardcoded)?
CHECK: are callbacks validating token claims properly?
CHECK: is the session maxAge appropriate for the use case?
CHECK: is access token refresh implemented in jwt callback?

ANTI_PATTERN: exposing access tokens to the client via session callback
FIX: only expose what the client NEEDS — roles, permissions, user info
ANTI_PATTERN: not refreshing expired access tokens
FIX: implement refresh logic in jwt callback (check expiresAt)

MIDDLEWARE_AUTH

// middleware.ts — protect routes server-side
import { auth } from '@/auth'
import { NextResponse } from 'next/server'

export default auth((req) => {
  const isAuthenticated = !!req.auth
  const isAuthPage = req.nextUrl.pathname.startsWith('/auth')
  const isAdminPage = req.nextUrl.pathname.startsWith('/admin')
  const isApiRoute = req.nextUrl.pathname.startsWith('/api')

  // redirect unauthenticated users to login
  if (!isAuthenticated && !isAuthPage) {
    return NextResponse.redirect(new URL('/auth/login', req.nextUrl))
  }

  // check admin role for admin pages
  if (isAdminPage) {
    const roles = req.auth?.user?.roles ?? []
    if (!roles.includes('admin')) {
      return NextResponse.redirect(new URL('/unauthorized', req.nextUrl))
    }
  }

  return NextResponse.next()
})

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'],
}

KEYCLOAK_REALM_SETUP

GE_USAGE: one Keycloak realm per client project
DEPLOYMENT: dedicated Keycloak instance per client OR shared with isolated realms

REALM_CONFIGURATION_CHECKLIST

STEP 1: Create realm
  - name: {client-name} (lowercase, no spaces)
  - display name: {Client Display Name}
  - enabled: true

STEP 2: Configure security defenses
  Realm Settings → Security Defenses:
  - brute force detection: ON
  - permanent lockout: OFF
  - max login failures: 5
  - wait increment: 60 seconds
  - max wait: 900 seconds (15 min)
  - failure reset time: 43200 (12 hours)

STEP 3: Configure session timeouts
  Realm Settings → Sessions:
  - SSO Session Idle: 30 minutes
  - SSO Session Max: 8 hours
  - Client Session Idle: 15 minutes
  - Client Session Max: 8 hours
  - Offline Session Idle: 30 days

STEP 4: Password policy
  Authentication → Password Policy:
  - length: 12
  - digits: 1
  - upperCase: 1
  - specialChars: 1
  - notUsername: true
  - passwordHistory: 5
  - forceExpiredPasswordChange: 90 (days)
  NOTE: add haveibeenpwned check if available via plugin

STEP 5: Required actions
  Authentication → Required Actions:
  - Verify Email: enabled, default
  - Configure OTP: enabled (not default — per admin choice)

STEP 6: Create client
  Clients → Create:
  - client ID: {project-frontend}
  - client authentication: ON (confidential)
  - authorization: OFF (unless fine-grained needed)
  - valid redirect URIs: exact production + staging URLs (NO wildcards)
  - web origins: exact origins (NO *)
  - standard flow: ON
  - direct access grants: OFF
  - implicit flow: OFF

STEP 7: Create roles
  Realm Roles → Create:
  - user (default role)
  - admin
  - {custom roles per project}
  Assign default role to new users

STEP 8: Configure token lifetimes
  Realm Settings → Tokens:
  - access token lifespan: 5 minutes
  - refresh token lifespan: 8 hours (revoked on logout)
  - access token lifespan for implicit flow: 0 (disabled)

KEYCLOAK_SECURITY_RULES

RULE: admin console MUST NOT be publicly accessible — restrict via NetworkPolicy or firewall
RULE: valid redirect URIs must be EXACT — never use wildcards
RULE: direct access grants (Resource Owner Password) MUST be disabled
RULE: implicit flow MUST be disabled
RULE: client secret MUST be in Vault — not in env vars or config files
RULE: regular audit of active sessions and registered clients

ANTI_PATTERN: wildcard redirect URI (https://*)
FIX: enumerate exact allowed URIs including trailing slashes

ANTI_PATTERN: using Keycloak admin API from frontend
FIX: admin API calls only from backend services with service account

ANTI_PATTERN: sharing one client across multiple applications
FIX: one Keycloak client per application (frontend, backend, mobile)


SESSION_MANAGEMENT

// REQUIRED cookie attributes for session cookies
{
  httpOnly: true,        // prevents JavaScript access (XSS protection)
  secure: true,          // HTTPS only
  sameSite: 'lax',       // CSRF protection (use 'strict' for admin apps)
  path: '/',             // scope to entire app
  maxAge: 8 * 60 * 60,   // 8 hours max (absolute timeout)
  domain: undefined,     // default to current domain — NEVER set to parent domain
}

RULE: session cookies MUST be httpOnly + Secure + SameSite
RULE: absolute session timeout (maxAge) — user must re-authenticate
RULE: idle session timeout — session expires after inactivity period
RULE: rotate session ID after login (prevent session fixation)
RULE: invalidate session server-side on logout (not just clear cookie)

TOKEN_ROTATION

FLOW: Access Token Rotation with Refresh Tokens

1. Client has valid access_token (5 min) + refresh_token (8 hr)
2. Access token expires
3. Client sends refresh_token to /token endpoint
4. Server validates refresh_token
5. Server issues NEW access_token + NEW refresh_token
6. Old refresh_token is INVALIDATED (rotation)
7. IF old refresh_token is reused → revoke ALL tokens for user (theft detection)

RULE: refresh token rotation MUST be enabled in Keycloak
RULE: reuse detection MUST be enabled — if old refresh token used, revoke all
RULE: refresh tokens stored server-side (httpOnly cookie or session store)

ANTI_PATTERN: refresh tokens in localStorage
FIX: httpOnly cookie with SameSite=Strict

ANTI_PATTERN: no refresh token rotation — stolen token usable indefinitely
FIX: enable rotation + reuse detection in Keycloak client settings


MFA_IMPLEMENTATION

TOTP (TIME-BASED ONE-TIME PASSWORD)

STANDARD: RFC 6238 (TOTP), RFC 4226 (HOTP base)

KEYCLOAK SETUP:
  Authentication → Required Actions → Configure OTP → enabled
  Authentication → OTP Policy:
  - OTP Type: Time-based
  - Algorithm: SHA-256 (or SHA-1 for compatibility)
  - Digits: 6
  - Period: 30 seconds
  - Look-ahead window: 1 (allows 1 period drift)
  - Initial counter: 0

WEBAUTHN_AS_MFA

RULE: WebAuthn can serve as second factor (after password) or standalone factor
NOTE: Keycloak supports WebAuthn as both MFA and passwordless

KEYCLOAK SETUP FOR WEBAUTHN MFA:
  Authentication → Required Actions → WebAuthn Register → enabled
  Authentication → WebAuthn Policy:
  - RP Entity Name: {client name}
  - Signature Algorithms: ES256, RS256
  - RP ID: {exact domain}
  - Attestation Conveyance: none
  - Authenticator Attachment: (empty — allow all)
  - Require Resident Key: No (for MFA — resident key for passwordless)
  - User Verification Requirement: required
  - Timeout: 60 seconds

MFA_RULES

RULE: admin accounts MUST have MFA enabled — no exceptions
RULE: MFA for regular users: required for financial operations, recommended for all
RULE: provide recovery codes (8-10 single-use codes) when MFA is set up
RULE: recovery codes stored as bcrypt hashes in database
RULE: support multiple MFA methods per user (TOTP + WebAuthn backup)

ANTI_PATTERN: SMS-based MFA — vulnerable to SIM swap attacks
FIX: use TOTP or WebAuthn — SMS only as last resort with risk acceptance

ANTI_PATTERN: MFA bypass for "remembered" devices with long expiry
FIX: device trust max 30 days, re-verify on sensitive operations regardless


API_AUTHENTICATION

JWT_VALIDATION_CHECKLIST

FOR EVERY INCOMING JWT:
  1. verify signature using JWKS endpoint public key (NOT hardcoded key)
  2. check alg header matches expected (RS256 or ES256 — NEVER 'none' or HS256 with public key)
  3. check iss matches expected issuer
  4. check aud contains your service identifier
  5. check exp > current time
  6. check iat is not too far in the past
  7. check nbf (not-before) if present
  8. extract and validate custom claims (roles, permissions, tenant_id)

ANTI_PATTERN: JWT algorithm confusion — accepting HS256 when RS256 expected
FIX: explicitly configure allowed algorithms, reject all others

ANTI_PATTERN: not validating aud claim — token for Service A works on Service B
FIX: always validate audience

ANTI_PATTERN: caching JWKS keys without refresh
FIX: cache with TTL (1 hour), force refresh on signature validation failure

SERVICE_TO_SERVICE_AUTH

PATTERN: Client Credentials flow for machine-to-machine

1. Service A authenticates with Keycloak:
   POST /token
   grant_type=client_credentials
   &client_id=service-a
   &client_secret={from Vault}
   &scope=service-b.read

2. Keycloak returns access_token (short-lived, 5 min)

3. Service A calls Service B:
   GET /api/resource
   Authorization: Bearer {access_token}

4. Service B validates token (signature, iss, aud, exp, scope)

RULE: service accounts use Client Credentials — not user tokens
RULE: service-to-service tokens have minimal scope (principle of least privilege)
RULE: client secrets stored in Vault — rotated every 90 days


COMMON_MISTAKES

ANTI_PATTERN: custom JWT library instead of proven one (jose, jsonwebtoken)
FIX: use jose (recommended for Edge runtime) or jsonwebtoken (Node.js)

ANTI_PATTERN: storing session data in JWT — JWT becomes bloated and can't be revoked
FIX: JWT for authentication, server-side session store for session data

ANTI_PATTERN: logging tokens or credentials
FIX: mask tokens in logs — show only last 4 characters

ANTI_PATTERN: using same Keycloak realm for dev/staging/production
FIX: separate realm per environment — different secrets, different users

ANTI_PATTERN: not handling token refresh errors gracefully
FIX: on refresh failure, redirect to login — never show broken state

ANTI_PATTERN: allowing password login alongside WebAuthn without requiring MFA
FIX: if WebAuthn is available, require it — password-only is a downgrade


SELF_CHECK

BEFORE_DEPLOYING_AUTH:
- [ ] challenge stored in httpOnly cookie (WebAuthn)?
- [ ] PKCE used for all OAuth flows?
- [ ] tokens validated server-side (signature, iss, aud, exp)?
- [ ] session cookies: httpOnly + Secure + SameSite?
- [ ] token rotation enabled for refresh tokens?
- [ ] MFA required for admin accounts?
- [ ] Keycloak admin console not publicly accessible?
- [ ] no tokens in localStorage?
- [ ] no credentials in logs?
- [ ] redirect URIs are exact (no wildcards)?


READ_ALSO: domains/security/vault-secrets.md, domains/security/index.md (A07:AUTHENTICATION_FAILURES)