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¶
COOKIE_CONFIGURATION¶
// 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)