Skip to content

Secure Authentication & Authorization

An OAuth 2.0, OpenID Connect & Keycloak Architecture Guide

A step-by-step guide that builds from first principles to a complete, production-grade authentication and authorization architecture. By the end you should be able to (1) reason about how you would build an Authorization Server from scratch, and (2) understand how Keycloak implements each of these concepts.


Table of contents

Part I — Foundations 1. 1. Why OAuth 2.0 exists 2. 2. The actors and the vocabulary 3. 3. The Authorization Code flow 4. 4. Token types and the back-channel

Part II — Flow variants 5. 5. PKCE — securing public clients 6. 6. Client Credentials — machine-to-machine 7. 7. Device Code — browserless devices 8. 8. The Implicit flow and why it was deprecated 9. 9. OAuth 2.0 vs OpenID Connect

Part III — Tokens in depth 10. 10. Anatomy of a JWT 11. 11. Validating a JWT 12. 12. Where to store tokens 13. 13. The Backend-for-Frontend (BFF) pattern

Part IV — Authorization 14. 14. Authentication vs Authorization 15. 15. The three layers of access control 16. 16. The dynamic-scopes problem 17. 17. Token enrichment hooks

Part V — Federation & user management 18. 18. Single Sign-On with external providers 19. 19. Just-in-time provisioning 20. 20. Why email is not a safe primary key 21. 21. Account linking

Part VI — Data architecture 22. 22. Data ownership: Auth Server vs Application 23. 23. Schemas, source of truth & deletion

Part VII — Multi-group platforms 24. 24. Realms vs clients vs groups vs tenants 25. 25. Subdomains 26. 26. Group-aware API enforcement

Part VIII — Two practical lenses 27. 27. Building an Auth Server from scratch 28. 28. How Keycloak maps to every concept 29. 29. The modern baseline

Verification notes (2026-10-05) · References

---

Part I — Foundations

1. Why OAuth 2.0 exists

Before OAuth, if an application wanted to act on your behalf against another service, the only option was to ask for your password and impersonate you. This is catastrophic: the app gets full access forever, the password can't be scoped or revoked without changing it, and every third-party app becomes a place your password can leak from.

OAuth 2.0 solves one specific problem: delegated authorization. It lets a user grant an application limited, revocable access to resources, without ever sharing their credentials with that application. The user authenticates directly with a trusted party (the Authorization Server), and the application receives only a scoped token.

Three principles fall out of this and recur throughout the entire guide:

  • The client app never sees the user's credentials. Only the Authorization Server does.
  • Access is scoped and revocable. A token grants specific permissions, not blanket access.
  • Secrets stay on servers. Anything sensitive travels over a server-to-server channel, never through the browser.

Everything else in this document is an elaboration of these three ideas.


2. The actors and the vocabulary

Four roles appear in nearly every flow. Fix these in your mind now — the rest of the guide refers to them constantly.

Actor Role
Resource Owner The user who owns the data and grants access.
Client The application requesting access on the user's behalf.
Authorization Server Authenticates the user and issues tokens. (Keycloak plays this role.)
Resource Server The API that holds protected data and validates tokens.

A few more terms used throughout:

  • Scope — a named permission requested by the client (e.g. read:orders).
  • Authorization code — a short-lived, single-use code exchanged for tokens.
  • Access token — proves the client may call the Resource Server.
  • Refresh token — used to obtain a new access token without re-login.
  • Front-channel — communication that passes through the user's browser (visible, logged).
  • Back-channel — direct server-to-server communication (invisible to the browser).

The front-channel / back-channel distinction is the single most important security concept in OAuth. Keep it in mind: valuable secrets and tokens belong on the back-channel.


3. The Authorization Code flow

This is the canonical, most secure OAuth 2.0 flow for applications that have a server component. Master this one and every other flow is a variation.

sequenceDiagram
    autonumber
    participant U as User Browser
    participant C as Client App
    participant A as Authorization Server
    participant R as Resource Server

    U->>C: Click "Sign in"
    C->>A: Authorization request<br/>client_id, redirect_uri, scope,<br/>response_type=code, state
    A->>U: Serve login & consent page
    U->>A: Submit credentials + approve scopes
    A-->>C: Redirect with ?code=abc123&state=xyz

    rect rgb(230, 240, 255)
    note over C,A: Back-channel — server-to-server only
    C->>A: code + client_secret  to  /token
    A-->>C: access_token + refresh_token
    end

    C->>R: API request + Authorization: Bearer token
    R-->>C: Protected resource data

Walking through the nine steps:

  1. Sign in — the user clicks a button. Nothing sensitive has moved yet.
  2. Authorization request — the client redirects the browser to the Auth Server's /authorize endpoint, carrying its client_id, a redirect_uri, the requested scope, response_type=code, and a random state value (CSRF protection; the client checks it when the browser comes back). Modern clients also send a PKCE code_challenge here (§5).
  3. Login & consent — the Auth Server serves its own login UI. The client never sees the credentials. The user also approves the requested scopes.
  4. Credentials submitted — the user authenticates directly with the Auth Server (password, passkey, MFA). On success the server mints a short-lived, single-use authorization code.
  5. Code redirect — the Auth Server redirects the browser back to the client's redirect_uri with the code in the URL. The code is useless on its own.
  6. Code exchange (back-channel) — the client's server makes a direct HTTPS call to the /token endpoint, sending the code plus its client_secret. This never touches the browser.
  7. Tokens issued — the Auth Server validates the code and secret, then returns an access token and a refresh token.
  8. API call — the client calls the Resource Server, attaching the access token as a Bearer credential.
  9. Resource returned — the Resource Server validates the token and returns the data.

4. Token types and the back-channel

Why the intermediate "code"?

You might ask why the Auth Server doesn't just hand over a token directly in step 5. The answer is the back-channel. Tokens are valuable; you never want them in a browser URL, which is logged in history, proxies, and analytics. The code is worthless without the client_secret (or, for a public client, the PKCE code_verifier, §5), so even if it leaks from the redirect URL, an attacker can do nothing with it. The real token only ever travels server-to-server.

The three credentials, compared

Credential Lifetime Purpose
Authorization code Seconds to a minute, single-use (RFC 6749 caps it at 10 min; Keycloak's default is 1 min) Exchange it for real tokens.
Access token Short-lived (see below) Prove authorization to the Resource Server.
Refresh token Long-lived (days–months) Obtain a new access token silently.

On access-token lifetime: access tokens are deliberately short-lived, but "short" is a trade-off, not a fixed number. Common values range from 5 minutes to 1 hour. The shorter the lifetime, the smaller the window in which a stolen — or logically revoked — token remains usable, at the cost of more frequent refreshes. A self-contained JWT (Part III) cannot be revoked before it expires, so if you need fast revocation, lean toward the shorter end (5–15 minutes). If revocation responsiveness is not critical, an hour is fine. Keycloak's default access-token lifespan is 5 minutes.

---

Part II — Flow variants

The Authorization Code flow assumes a confidential client (one that can keep a secret). Real systems include clients that can't, plus non-interactive scenarios. OAuth defines variants for each.

5. PKCE — securing public clients

Single-page apps (SPAs) and mobile apps cannot safely hold a client_secret — anything shipped to the browser or an app bundle can be extracted. PKCE (Proof Key for Code Exchange) is the modern extension that gives these "public" clients the same two-step security without a static secret.

Instead of a secret, the client generates a fresh random code_verifier for each login (43–128 characters), hashes it into a code_challenge, and sends the challenge with the initial request. It proves possession of the verifier later, during the token exchange.

sequenceDiagram
    autonumber
    participant C as Public Client SPA
    participant A as Authorization Server

    Note over C: Generate random code_verifier<br/>code_challenge = BASE64URL(SHA256(code_verifier))
    C->>A: /authorize + code_challenge + code_challenge_method=S256
    A-->>C: redirect with ?code=abc123
    C->>A: /token + code + code_verifier
    Note over A: Verify BASE64URL(SHA256(code_verifier))<br/>equals stored code_challenge
    A-->>C: access_token + refresh_token

What PKCE does and does not prove. It proves that whoever redeems the code is the same party that started the flow, so an intercepted or injected code is useless. It does not prove which application that party is: a public client still has no identity proof. Always use S256; the plain method exists only for clients that cannot hash.

PKCE is now expected for all clients: RFC 9700 says public clients MUST use it and it is RECOMMENDED for confidential clients, and the OAuth 2.1 draft requires it for the Authorization Code flow. It adds defense in depth at negligible cost and is the direct replacement for the Implicit flow (§8).

6. Client Credentials — machine-to-machine

When there is no user — a cron job, a microservice, a backend integration — the client authenticates as itself with its client_id + client_secret and receives an access token directly. No redirect, no consent page, no refresh token.

sequenceDiagram
    autonumber
    participant S as Service Client
    participant A as Authorization Server
    S->>A: /token<br/>grant_type=client_credentials<br/>client_id + client_secret
    A-->>S: access_token

This flow becomes important again in §27 / Part VIII, where a BFF uses a service account (an instance of this flow) to call an admin API on behalf of the system.

7. Device Code — browserless devices

For devices with no usable browser or keyboard (smart TVs, CLI tools, IoT), defined in RFC 8628. The device shows a short code; the user approves it on a phone or laptop; the device polls the token endpoint at the advertised interval (backing off on slow_down) until approval comes through.

sequenceDiagram
    autonumber
    participant D as Device
    participant A as Authorization Server
    participant U as User phone
    D->>A: request device_code + user_code
    A-->>D: codes + verification URL
    D->>U: Go to URL, enter ABCD-1234
    U->>A: visits URL, logs in, approves
    loop until approved
        D->>A: poll /token with device_code
    end
    A-->>D: access_token

8. The Implicit flow and why it was deprecated

The Implicit flow was an early-2010s workaround for SPAs, back when browsers couldn't make the cross-origin back-channel calls the Authorization Code flow requires. It skipped the code exchange entirely: the Auth Server returned the access token directly in the redirect URL fragment.

sequenceDiagram
    autonumber
    participant B as Browser / SPA
    participant A as Authorization Server
    participant R as Resource Server
    B->>A: /authorize response_type=token
    A->>B: login & consent
    B->>A: user grants access
    rect rgb(255, 230, 230)
    note over A,B: DANGER — token in the URL fragment
    A-->>B: redirect with #access_token=eyJ...
    end
    B->>R: API request + Bearer token
    R-->>B: protected data

Why this is less secure — the concrete attack surface. A fragment is never sent to a server, so it does not end up in proxy or server access logs. But inside the browser, once the token lands in the URL fragment, it is exposed to a surprising number of places:

  • Browser history (anyone with device access retrieves it).
  • window.location, readable by any script on the page — including an injected ad or an XSS payload — and by browser extensions.
  • Redirects: browsers re-attach a fragment to a Location header that has none, so an open redirector on the client's domain can forward the token to an attacker.
  • The Referer header, through browser implementation issues (RFC 9700 §4.2).
  • Token injection: nothing binds the token in the URL to the client that asked for it, so an attacker can swap in a token they obtained elsewhere.

And the Implicit flow has no client authentication and no PKCE, so the Auth Server cannot tell who will end up holding the token. There is also no refresh token, so when the short-lived access token expires, the user must log in again.

Status: RFC 9700 (OAuth 2.0 Security Best Current Practice, January 2025) says clients SHOULD NOT use the Implicit grant (or any response type that returns an access token in the authorization response), and the OAuth 2.1 draft drops it altogether. Modern browsers fully support CORS and the back-channel, so the original excuse is gone. The fix is PKCE (§5), which gives SPAs the same two-step security with no static secret. There is no good reason to use the Implicit flow today.

The same RFC goes further for the Resource Owner Password Credentials grant, where the app collects the user's password and sends it to /token: it MUST NOT be used, because it brings back exactly the password-sharing problem of §1.

flowchart LR
    A[Implicit flow] -->|token in URL fragment| B[Exposed in history, JS,<br/>extensions, redirects]
    A -->|nothing binds token to client| C[Token injection possible]
    A -->|no refresh token| D[Frequent re-login]
    E[Authorization Code + PKCE] -->|only a single-use code<br/>touches the URL| F[Token exchanged on back-channel]
    E -->|verifier binds code to<br/>the flow that started it| G[No static secret needed]
    style A fill:#ffe6e6
    style E fill:#e6f7e6

9. OAuth 2.0 vs OpenID Connect

A crucial distinction that trips up many teams:

  • OAuth 2.0 is an authorization protocol. It answers "what is this app allowed to do?" It says nothing about who the user is.
  • OpenID Connect (OIDC) is a thin identity layer on top of OAuth 2.0. It adds an id_token (a JWT describing the user's identity) and a /userinfo endpoint.

When you see "Sign in with Google," that is OIDC riding on OAuth 2.0. Throughout this guide, the access token carries authorization data; the id_token (when present) carries authentication data.

Three practical rules follow:

  • A client turns OIDC on by including openid in the scope (scope=openid profile email).
  • The id_token is for the client, its aud is the client's own client_id. Never send it to an API as a bearer token; that is what the access token is for.
  • The client sends a random nonce in the authorization request and checks that the id_token carries the same value, which stops a stolen id_token from being replayed.

---

Part III — Tokens in depth

10. Anatomy of a JWT

Most modern access tokens are JWTs (JSON Web Tokens): self-contained, signed documents that carry identity and authorization data inside them. The Resource Server can validate a JWT and read who the user is without calling anyone.

A JWT has three base64url-encoded parts separated by dots:

header.payload.signature

The payload decodes to a plain JSON object called claims:

{
  "sub": "user_123",
  "email": "wim@example.com",
  "name": "Wim Suenens",
  "roles": ["editor", "admin"],
  "scope": "read:orders write:orders",
  "iss": "https://auth.yourapp.com",
  "aud": "https://api.yourapp.com",
  "iat": 1716809200,
  "exp": 1716812800
}

Key claims:

  • sub (subject) — the stable, unique user identifier. This is the identity anchor for the entire system.
  • scope / roles — authorization data.
  • iss (issuer) — which Auth Server minted the token.
  • aud (audience) — which API the token is intended for.
  • exp / iat — expiry and issued-at timestamps.
  • jti — a unique token ID, useful for revocation lists (§11).

Two things to keep in mind:

  • Base64url is encoding, not encryption. Anyone holding the token can read every claim (paste one into a decoder). Never put secrets in a JWT.
  • RFC 9068 standardises JWT access tokens: header typ: at+jwt, and required claims iss, exp, aud, sub, client_id, iat, jti. The client should still treat the access token as opaque: only the Resource Server reads it. Not every server issues JWTs; some issue opaque reference tokens that the API checks through /introspect (RFC 7662).

11. Validating a JWT

The signature is what makes a JWT trustworthy. The Auth Server signs the token with its private key (RS256 or ES256). The Resource Server validates it with the matching public key. It finds the key set through the discovery document and caches it:

GET https://auth.yourapp.com/.well-known/openid-configuration
→ { "issuer": "...", "jwks_uri": "...", ... }

Keycloak: https://kc.yourapp.com/realms/{realm}/protocol/openid-connect/certs

The JWKS path differs per server, so read jwks_uri rather than guessing it. Each key has a kid; when a token arrives with a kid that is not in the cache, refetch the set (this is how key rotation reaches the API).

Validation is a strict, ordered sequence — every step must pass:

flowchart TD
    A[Parse JWT into<br/>header, payload, signature] --> A2[Check alg against an allowlist<br/>e.g. RS256 only, never none]
    A2 --> B[Verify signature<br/>with public key from JWKS, by kid]
    B --> C[Check exp and nbf:<br/>token valid right now]
    C --> D[Verify iss:<br/>expected Auth Server]
    D --> E[Verify aud:<br/>token issued for THIS API]
    E --> F[Check scope / roles:<br/>permission for endpoint]
    F --> G[Extract identity from claims:<br/>sub, email, roles]
    A2 -.fail.-> X[401 Unauthorized]
    B -.fail.-> X
    C -.fail.-> X
    D -.fail.-> X
    E -.fail.-> X
    F -.fail.-> Y[403 Forbidden]
    style G fill:#e6f7e6
    style X fill:#ffe6e6
    style Y fill:#ffe6e6

The iss and aud checks are frequently skipped and must not be:

  • iss ensures the token was minted by your Auth Server, not some other one. Without it, a token from a different system could be replayed against your API.
  • aud ensures the token was intended for your API specifically. Without it, a token issued for your mobile app could be replayed against your backend — a confused deputy attack.

The alg check stops two classic attacks: a token whose header says alg: none (no signature at all), and algorithm confusion, where an attacker signs with HS256 using your public key as the HMAC secret. Pin the algorithms you expect; good libraries make you.

401 or 403? RFC 6750 maps a missing, expired or badly signed token to 401 (invalid_token) and a valid token without the right scope to 403 (insufficient_scope).

A minimal middleware (Nuxt / Nitro, using jose):

// server/middleware/auth.ts
import { jwtVerify, createRemoteJWKSet } from 'jose'

// the jwks_uri value from https://auth.yourapp.com/.well-known/openid-configuration
const JWKS = createRemoteJWKSet(new URL(JWKS_URI))

export default defineEventHandler(async (event) => {
  const token = getHeader(event, 'authorization')?.replace('Bearer ', '')
  if (!token) throw createError({ statusCode: 401 })

  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://auth.yourapp.com',
    audience: 'https://api.yourapp.com',
    algorithms: ['RS256'],       // the alg allowlist
  })                             // exp and nbf are checked by default

  event.context.user = payload  // verified, trusted identity
})

createRemoteJWKSet caches the key set and refetches it when it meets an unknown kid.

Trade-off — revocation. A self-contained JWT cannot be revoked before exp. If a user logs out or is suspended, the token stays technically valid until it expires. Two mitigations:

  • Keep access tokens short-lived (5–15 min) so the damage window is small.
  • Maintain a revocation list (e.g. a Redis set of revoked jti values) and check it as an extra step. This reintroduces a per-request lookup, but only when revocation is a hard requirement.

12. Where to store tokens

Once the browser is involved, where the token lives determines your exposure. Storing tokens in cookies is generally better than localStorage, but only if the cookie is configured correctly. Three attributes are mandatory:

Attribute Effect Without it
HttpOnly JavaScript cannot read the cookie XSS steals the token via document.cookie
Secure Cookie only sent over HTTPS Token travels in plaintext
SameSite=Strict/Lax Blocks cross-site sending CSRF can forge authenticated requests

Without HttpOnly in particular, a cookie is no safer than localStorage against XSS — the most common SPA attack vector. Two refinements:

  • HttpOnly stops theft, not misuse. An XSS payload cannot read the cookie, but it can still make requests from the page that carry it. Preventing XSS (CSP, output encoding) stays essential.
  • Strict vs Lax. A Strict cookie is not sent on the top-level redirect from the Auth Server back to your callback, because that navigation starts on another site. Cookies that must survive the login round-trip (the state / PKCE verifier of a login in progress) need Lax. SameSite also treats sibling subdomains as same-site, so it is not a complete CSRF defence: add a CSRF token or require a custom header on state-changing requests. Use the __Host- name prefix to pin a cookie to one host, HTTPS and Path=/.

Two patterns:

  • Pattern A — tokens directly in cookies. Works, but you ship a live bearer credential to the browser on every request. If anything leaks (misconfigured CORS, subdomain takeover, a rogue iframe), the attacker has a working token. The refresh token is especially sensitive.
  • Pattern B — the BFF (next section). The browser holds only an opaque session ID; the real tokens never leave the server. This is the current recommendation for browser apps.

Refresh-token specifics (regardless of pattern):

  • If stored client-side, give it its own HttpOnly; Secure; SameSite=Strict cookie, scoped via Path=/auth/refresh so it is sent only to the refresh endpoint.
  • Implement refresh-token rotation: every use invalidates the old token and issues a new one. A replayed old token signals theft → revoke the whole token family. For public clients RFC 9700 makes this mandatory: their refresh tokens MUST be rotated or sender-constrained (bound to a key the client holds, e.g. DPoP, RFC 9449).
  • Set a hard absolute expiry (e.g. 30 days), not just a sliding window.

13. The Backend-for-Frontend (BFF) pattern

In the BFF pattern, tokens stay entirely server-side. The browser holds only an opaque session ID in a cookie; the BFF exchanges that for the real token before forwarding requests. The token never reaches the browser at all.

A framework like Nuxt is a natural fit because it is not just a frontend — it ships with a built-in server runtime (Nitro) that runs alongside the Vue app. That server layer is the BFF: server/routes/api/** are your own endpoints, where you attach the access token and proxy to the Resource Server.

flowchart LR
    subgraph Browser
      V[Vue SPA<br/>holds only session cookie]
    end
    subgraph BFF[Nuxt Server / Nitro — the BFF]
      P["server/routes/api/**<br/>session lookup, refresh, proxy"]
      S[(Session store<br/>Redis / memory<br/>sessionId to tokens)]
    end
    A[Auth Server]
    R[Resource API]

    V -->|GET /api/x + cookie| P
    P --> S
    P -.token refresh.-> A
    P -->|Bearer token| R
    R -->|data| P
    P -->|data only| V

    style V fill:#e6f7f2
    style P fill:#f3e6ff

What never leaves the Nuxt server: access_token, refresh_token, client_secret. What the browser holds: an opaque session-ID cookie (__Host- prefixed, HttpOnly; Secure; SameSite), meaningless to an attacker without access to the server's session store. The BFF is a confidential client: it does the Authorization Code flow with PKCE and a client_secret.

Because the browser now authenticates with a cookie, the BFF must defend against CSRF: SameSite plus a custom header (e.g. X-CSRF: 1) or a CSRF token on every state-changing request. And note what the BFF does not fix: an XSS payload can still call the BFF from the page. It just can't steal a token to use elsewhere.

The IETF document OAuth 2.0 for Browser-Based Applications (draft-ietf-oauth-browser-based-apps, approved and in the RFC Editor queue) describes the BFF as the most secure of its browser architectures.

Practical recommendation: if you have any server layer (Nuxt's server/ gives you one for free), use the BFF. A pure SPA with no server at all cannot set HttpOnly cookies (JavaScript can't create them), so its second-best is: Authorization Code + PKCE, access token kept in memory only, short lifetimes, and refresh tokens that are rotated (or DPoP-bound). Avoid localStorage for tokens.

---

Part IV — Authorization

14. Authentication vs Authorization

A token that validates only tells the Resource Server who the user is. It does not automatically grant access to anything. The Resource Server owns authorization — the Auth Server merely vouches for identity.

  • Authentication — who is this? → the sub claim.
  • Authorization — what may they do? → scope, roles, and your own business rules.

This separation is the heart of Part IV: a valid token from a legitimate user is not the same as permission to touch any given piece of data.

15. The three layers of access control

A well-designed system enforces access at three layers. Each catches what the previous one cannot.

flowchart TD
    REQ[Incoming API request] --> L1
    L1{"Layer 1 — Scope<br/>token has required scope?"} -->|no| D1["403 insufficient_scope"]
    L1 -->|yes| L2
    L2{"Layer 2 — Role<br/>user role permits action?"} -->|no| D2[403]
    L2 -->|yes| L3
    L3{"Layer 3 — Resource ownership<br/>record belongs to user?"} -->|no| D3["403 prevents BOLA"]
    L3 -->|yes| OK[200 — handled]
    style OK fill:#e6f7e6
    style D1 fill:#ffe6e6
    style D2 fill:#ffe6e6
    style D3 fill:#ffe6e6

Layer 1 — Scope (coarse, Auth Server + API). Broad capabilities baked into the token at login (read:orders, write:invoices). The API rejects a request whose token lacks the required scope before doing anything else.

Layer 2 — Roles (your logic). Custom claims populated from your user store at token issuance. Enforced per endpoint:

export function requireRole(role: string) {
  return (event: H3Event) => {
    if (!event.context.user.roles?.includes(role))
      throw createError({ statusCode: 403 })
  }
}

Layer 3 — Resource ownership (always your logic). Scopes and roles answer "can this type of user do X?" They say nothing about "can this user touch this record?" That check must run in your business logic, every time:

// GET /api/orders/[id]
const order = await db.orders.findById(orderId)
if (!order) throw createError({ statusCode: 404 })
if (order.userId !== event.context.user.sub)
  throw createError({ statusCode: 403 })   // ownership check
return order

Omitting Layer 3 is Broken Object Level Authorization (BOLA) — the #1 entry on the OWASP API Security Top 10. The danger is that Layers 1 and 2 give a false sense of security: any authenticated user who guesses a record ID reads someone else's data.

Worked example. A regular user from tenant A authenticates successfully. DELETE /invoices/123 → Layer 1 blocks (no write:invoices scope). GET /admin/users → Layer 2 blocks (no admin role). GET /orders/789 belonging to tenant B → Layers 1 & 2 pass; only Layer 3 catches it, by checking order.tenantId === token.tenantId.

16. The dynamic-scopes problem

Scopes are static by design — coarse capabilities agreed at design time. They break down the moment the resources they protect are created dynamically (e.g. user-defined categories).

Do not try to express per-resource access as scopes. The patterns are:

  • Pattern A (default) — coarse scope + dynamic Layer-3 check. Keep scopes broad (read:categories); store the actual per-resource permission map in your database; consult it on every request.
// Layer 1 already confirmed 'read:categories'
const perm = await db.userPermissions.findOne({ userId, categoryId })
if (!perm) throw createError({ statusCode: 403 })

When a new category is created, you insert permission rows — the token never changes; the DB is the source of truth.

  • Pattern B (limited) — inject a permission list as a custom claim. Only viable for small, slowly-changing, bounded permission sets. Limits: token size (~8 KB practical max), and the data is stale the moment the token is issued.

For genuinely dynamic resources, Pattern A is almost always correct.

17. Token enrichment hooks

How do your own roles, tenantId, or org_id get into the token? Via an enrichment hook — a function that runs server-side on the Auth Server just before the token is issued, where you can query your own database and inject custom claims. These are provider-specific, but virtually every serious Auth Server offers them.

sequenceDiagram
    autonumber
    participant C as Client App
    participant A as Authorization Server
    participant H as Enrichment hook
    participant DB as Your DB

    C->>A: code + client_secret /token
    Note over A: validate code
    A->>H: trigger hook with user context
    H->>DB: query roles / permissions
    DB-->>H: roles, tenantId, permissions
    H-->>A: inject custom claims
    A-->>C: signed JWT with enriched claims

Auth0 — Actions (post-login):

exports.onExecutePostLogin = async (event, api) => {
  const ns = 'https://api.yourapp.com/'
  const { roles, tenantId } = await fetchUserFromYourDB(event.user.user_id)
  api.accessToken.setCustomClaim(`${ns}roles`, roles)
  api.accessToken.setCustomClaim(`${ns}tenantId`, tenantId)
}

Auth0 used to require a namespaced (URL-shaped) name for custom claims; since 2022 plain names are allowed except for reserved ones, but a namespace is still the safe habit.

Keycloak — Protocol Mappers (per client or per client scope) put roles, groups and user attributes into the token with built-in mapper types (User Attribute, Group Membership, User Realm Role, Hardcoded claim, …). For custom logic you write a Java protocol-mapper provider (SPI). The JavaScript Script Mapper still exists, but since Keycloak 18 scripts can no longer be uploaded through the admin console: they must be packaged in a JAR and deployed to the server's providers/ directory. More commonly with Keycloak you store roles and attributes in its own user store and map them in — no external call needed.

Provider Mechanism Configured in
Auth0 Actions (post-login) Dashboard / CLI
Keycloak Protocol Mappers / Java mapper SPI / Script Mapper (JAR) Admin console / deployed code
Okta Token Inline Hooks Dashboard + your webhook
Azure Entra ID Custom claims provider App registration + external API

---

Part V — Federation & user management

18. Single Sign-On with external providers

With external SSO (Google, Microsoft, GitHub), you no longer control token issuance — the external provider signs its own token. The solution is a federated identity layer: your Auth Server sits in the middle as an identity broker. The external provider authenticates the user; your Auth Server then issues its own enriched token. Your application only ever sees your token; the external provider's token is an internal detail your Auth Server consumes and discards.

sequenceDiagram
    autonumber
    participant B as Browser
    participant A as Your Auth Server
    participant E as External provider
    participant DB as Your DB

    B->>A: login request
    A-->>B: redirect to external provider
    B->>E: login at external provider
    E-->>B: redirect back with ext. auth code
    B->>A: forward ext. auth code

    rect rgb(230, 240, 255)
    note over A,DB: federation & enrichment — server-side only
    A->>E: exchange code for ext. id_token
    E-->>A: id_token their JWT
    A->>DB: lookup / provision user
    DB-->>A: roles, tenantId, permissions
    end

    A-->>B: YOUR enriched JWT<br/>iss: auth.yourapp.com

The resulting token contains your claims (sub, roles, tenantId, iss), never Google's. This keeps your Resource API fully decoupled from the external provider — switch providers tomorrow and your tokens and authorization logic are unchanged.

19. Just-in-time provisioning

On a user's first SSO login they don't yet exist in your database. The enrichment hook provisions them, then enriches (Auth0 Action shown; note that Auth0's user_id is its own provider|id string, so the code keys on that rather than on the raw upstream sub):

exports.onExecutePostLogin = async (event, api) => {
  const ns = 'https://api.yourapp.com/'
  const provider = event.connection.strategy      // 'google-oauth2', 'waad', ...
  const sub      = event.user.user_id             // provider's stable ID
  const email    = event.user.email

  let identity = await db.identities.findOne({ provider, sub })
  if (!identity) {
    const user = await db.users.create({ email, roles: ['viewer'] })
    identity = await db.identities.create({ provider, sub, email, userId: user.id })
  }
  const user = await db.users.findById(identity.userId)
  api.accessToken.setCustomClaim(`${ns}userId`, user.id)
  api.accessToken.setCustomClaim(`${ns}roles`, user.roles)
}

Read provider claims (given_name, picture) only to prepopulate your profile — never let them flow directly into your access token. Always go through your own DB.

20. Why email is not a safe primary key

Using email to look up a user's roles bundles several distinct risks:

flowchart LR
    G["Google<br/>sub: google-116abc<br/>email: wim@example.com"] -->|lookup by email| X
    M["Microsoft<br/>sub: aad-9f3d22<br/>email: wim@example.com"] -->|lookup by email| X
    X["Same DB row?!<br/>could be different people"]
    style X fill:#ffe6e6

    G2["google + 116abc to user_1"] --> OK["distinct identities<br/>no collision"]
    M2["aad + 9f3d22 to user_2"] --> OK
    style OK fill:#e6f7e6
  • Risk 1 — recycled addresses. Corporate emails get reassigned to new hires; the new owner would inherit the old owner's access.
  • Risk 2 — same email ≠ same person across providers. No global authority guarantees that wim@example.com at Google and at Microsoft are the same human. In some providers a user or tenant admin can set the email to anything: the nOAuth misconfiguration (Descope, 2023) let attackers take over accounts in apps that keyed Microsoft Entra logins on the email claim.
  • Risk 3 — email_verified is not always true. Never trust an unverified email as an identity anchor; always check the claim.

The correct key is the composite (issuer, sub). OIDC guarantees that sub is stable and unique within one issuer, and never reassigned; it does not change when the user changes their email. In the tables below provider stands for the issuer. One provider-specific catch: Microsoft Entra issues a pairwise sub (different per application), so for Entra the recommended stable key is the tenant + object ID pair (tid, oid).

-- internal identity
users      ( id PK, email, roles )
-- one row per provider login
identities ( provider, sub, email_at_login, user_id FK,
             PRIMARY KEY (provider, sub) )

21. Account linking

To let one user connect multiple providers to a single account, the link must be an intentional, authenticated action — never an automatic merge on matching email:

  1. User is logged in via Google → authenticated as user_1.
  2. In account settings, clicks "Connect Microsoft account."
  3. Completes the Microsoft login flow.
  4. Because they were already authenticated, the system links (microsoft, 9f3d22) to user_1.

Auto-merging on email skips that proof of ownership — exactly the account pre-hijacking class of attacks (Sudhodanan & Paverd, 2022): an attacker registers an account with your email before you do (for example with a password, never verifying it), you later sign in with Google using the same email, the service merges the two, and the attacker still holds a way in.

Keycloak's default first broker login flow follows the safe pattern: when an external login matches an existing account, it asks the user to confirm and to re-authenticate to that existing account (or verify by email) before it links the identity.

---

Part VI — Data architecture

22. Data ownership: Auth Server vs Application

A federated setup has two databases with a clean ownership boundary, linked by sub. The Auth Server (e.g. Keycloak) owns identity; your application owns everything domain-specific. Resist merging them.

flowchart LR
    subgraph KC[Keycloak DB — owns IDENTITY]
      direction TB
      K1[credentials<br/>passwords, MFA, passkeys]
      K2[sub — immutable user ID]
      K3[login email, username]
      K4[realm roles]
      K5[federated identities]
      K6[sessions, tokens]
    end
    subgraph APP[Application DB — owns DOMAIN]
      direction TB
      A1["users<br/>id PK + keycloak_sub link<br/>+ cached email"]
      A2[profiles<br/>display_name, avatar, prefs]
      A3[fine-grained permissions]
      A4["orders, invoices<br/>FK to users.id"]
      A5[audit log]
    end
    K2 ===|sub = keycloak_sub| A1
    style KC fill:#fff0ee
    style APP fill:#eef3ff

Golden rule: domain tables never FK to keycloak_sub directly — always to your internal users.id. Only one column knows Keycloak exists.

Why not just extend Keycloak? Keycloak does support custom user attributes, and since Keycloak 24 the declarative User Profile adds validation, per-attribute permissions and registration-form rendering. But they remain flat (multi-valued) strings with no relations, limited query support, and they bloat tokens if mapped into claims. Keycloak is an identity provider, not a profile store. Structured profile data belongs in your own database.

23. Schemas, source of truth & deletion

Application schema — an internal users table owns its own PK; keycloak_sub is the only column that knows Keycloak:

CREATE TABLE users (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- YOUR id
  keycloak_sub  TEXT UNIQUE NOT NULL,                        -- the link
  email         TEXT NOT NULL,                               -- cached copy
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_login_at TIMESTAMPTZ
);

CREATE TABLE profiles (
  user_id      UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
  display_name TEXT, avatar_url TEXT, bio TEXT,
  locale       TEXT DEFAULT 'en', timezone TEXT DEFAULT 'UTC',
  preferences  JSONB DEFAULT '{}'
);

CREATE TABLE orders (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id    UUID NOT NULL REFERENCES users(id),   -- internal FK, never keycloak_sub
  total      NUMERIC(10,2) NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

The internal id provides decoupling: migrate IdP or change sub format → update one column, every domain FK stays intact.

Just-in-time sync keeps the bridge current; upsert by keycloak_sub (never email) on each login:

const user = await db.insertInto('users')
  .values({ keycloak_sub, email, last_login_at: new Date() })
  .onConflict(c => c.column('keycloak_sub').doUpdateSet({ email, last_login_at: new Date() }))
  .returningAll().executeTakeFirstOrThrow()

Optimization: inject your internal user.id as a custom claim, so the Resource API gets it straight from the token and only reads the DB when it actually needs profile data.

Source of truth — who wins on overlap:

Data Source of truth Note
Password, MFA, passkeys Keycloak App never sees these
sub Keycloak Immutable anchor
Login email Keycloak App caches a copy; resync on login
Display name, avatar, bio App DB Seeded from provider, then app-owned
Coarse roles Keycloak Flow into the token
Fine-grained permissions App DB Never in the token
Domain data App DB Always

Deletion policy — decide upfront:

  • Soft delete (recommended): mark users.deleted_at, retain domain data for audit/legal reasons, block login. Best default, especially with invoices subject to retention rules.
  • Cascade: delete from Keycloak and your DB. Riskier — do the Keycloak delete last, only after the app side succeeds, to avoid inconsistency.

---

Part VII — Multi-group platforms

Scenario: an internal platform where parts of the data are shared with external groups — customers, suppliers, 3PL providers — each with limited access to parts of the Resource API.

24. Realms vs clients vs groups vs tenants

The right answer for one platform, multiple external parties, shared data is almost always:

One realm. Multiple clients. Groups for categorization. An org_id for data isolation.

  • Separate realms mean separate user pools, separate login flows, and a different token issuer per realm, so the API has to trust several issuers and nothing is shared. Reserve them for a true multi-product SaaS selling isolated instances — not this scenario.
  • Groups carry categorization (/internal, /customers, /suppliers, /3pl-providers).
  • Sub-groups carry the organization (/customers/org_abc).
  • Roles carry capabilities (order_viewer, shipment_updater).
flowchart TD
    subgraph Realm[Keycloak single realm: yourapp]
      direction TB
      subgraph Clients
        C1[internal-app]:::c
        C2[customer-portal]:::c
        C3[supplier-portal]:::c
        C4[3pl-portal]:::c
        C5[resource-api / BFF]:::api
      end
      subgraph Groups
        G1["/internal<br/>roles: admin, ops, analyst"]:::g
        G2["/customers/org_*<br/>roles: order_viewer, order_placer"]:::g
        G3["/suppliers/org_*<br/>roles: catalogue_*, po_viewer"]:::g
        G4["/3pl-providers/org_*<br/>roles: shipment_*, stock_viewer"]:::g
      end
    end
    classDef c fill:#e6f7f2;
    classDef api fill:#f3e6ff;
    classDef g fill:#eef3ff;

A user assigned to /customers/org_abc automatically inherits that group's roles. Onboarding a new customer = create a sub-group, assign default roles, add users. No token logic changes.

The group path becomes the source of user_type and org_id. Keycloak has no Auth0-style "Actions"; the simplest route is the built-in Group Membership protocol mapper with Full group path on, which puts the paths into the token as-is:

{
  "sub": "5f1c…",
  "groups": ["/customers/org_abc"],
  "realm_access": { "roles": ["order_viewer", "invoice_viewer"] },
  "scope": "openid read:orders read:invoices"
}

The Resource API then derives user_type and org_id from the path once, in the auth middleware:

// server/utils/principal.ts — '/customers/org_abc' → { userType: 'customer', orgId: 'org_abc' }
const TYPES: Record<string, string> = {
  internal: 'internal', customers: 'customer', suppliers: 'supplier', '3pl-providers': '3pl-provider',
}
export function principalFrom(claims: { groups?: string[] }) {
  for (const path of claims.groups ?? []) {
    const [kind, org] = path.split('/').filter(Boolean)
    if (TYPES[kind]) return { userType: TYPES[kind], orgId: org ?? null }
  }
  return { userType: 'unknown', orgId: null }
}

If you would rather have ready-made user_type / org_id claims in the token, write a custom Java protocol mapper (§17). The rest of this part assumes the middleware stores the result in event.context.user.userType and event.context.user.orgId.

Keycloak Organizations: the native alternative

Since Keycloak 26 (preview in 25) there is a first-class Organizations feature built for exactly this "one realm, many external parties" case. An organization is a tenant inside the realm with its own members, attributes, email domains and linked identity providers (so @acme.com users can be routed to Acme's own IdP). Request the built-in organization client scope (scope=organization, or organization:acme for a specific one) and the token carries the membership. By default that is just the alias list, "organization": ["acme"]; enable Add organization id on the Organization Membership mapper to get:

{ "organization": { "acme": { "id": "42c3e46f-…" } } }

Organizations can also have their own groups (added in 2026), which then appear inside that claim ("groups": ["/Engineering/Backend"], paths relative to the organization). Choose Organizations when each external party is a real tenant that may bring its own IdP; plain groups remain fine for simple categorisation. Either way, the API still enforces org_id on every query (§26).

25. Subdomains

Separate subdomains (internal.app.com, customers.app.com, …) are a frontend decision, not an auth decision. They are worth it when:

  • the UI differs radically per group,
  • you want different login branding per group (Keycloak per-client themes), or
  • you want to restrict which identity providers appear per group (internal → Microsoft SSO only; external → password + Google).

In Keycloak, each subdomain gets its own client within the same realm — different login theme, redirect URIs, default scopes — while sharing one user pool, one set of groups, and one enrichment logic. Best of both worlds. Restricting identity providers per client is not a single checkbox: use a per-client authentication flow override, send kc_idp_hint from the client to jump straight to one IdP, or let Organizations route users to their IdP by email domain.

26. Group-aware API enforcement

With user_type, org_id, and roles in the token, the Resource API composes all four checks:

flowchart TD
    T["Token claims<br/>user_type, org_id, roles, scope"] --> L1
    L1["1. scope — can this token type call this endpoint?"] --> L2
    L2["2. user_type — is this group allowed here?"] --> L3
    L3["3. roles — read-only or write?"] --> L4
    L4["4. org_id — WHERE org_id = token.org_id<br/>internal = no filter"] --> OK[data returned]
    style OK fill:#e6f7e6
// server/utils/authorize.ts  (userType / orgId set by principalFrom(), §24)
export function allowUserTypes(...types: string[]) {
  return (event: H3Event) => {
    if (!types.includes(event.context.user.userType)) throw createError({ statusCode: 403 })
  }
}
export function scopeToOrg(event: H3Event) {
  const { userType: ut, orgId: org } = event.context.user
  if (ut === 'internal') return null                       // sees everything
  if (!org) throw createError({ statusCode: 403 })
  return org                                                // external = scoped
}

// server/routes/api/orders/index.get.ts
export default defineEventHandler(async (event) => {
  allowUserTypes('internal', 'customer')(event)            // Layer 2
  const orgId = scopeToOrg(event)                          // Layer 4
  return db.selectFrom('orders').selectAll()
    .$if(orgId !== null, qb => qb.where('customer_org_id', '=', orgId!))
    .execute()
})

Supporting tables:

CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  keycloak_sub TEXT UNIQUE NOT NULL,
  email TEXT NOT NULL,
  user_type TEXT NOT NULL,        -- internal | customer | supplier | 3pl-provider
  org_id TEXT                     -- NULL for internal
);
CREATE TABLE organizations (
  id TEXT PRIMARY KEY,            -- 'org_abc', matches Keycloak group name
  name TEXT NOT NULL,
  type TEXT NOT NULL             -- customer | supplier | 3pl-provider
);
CREATE TABLE orders (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_org_id TEXT NOT NULL REFERENCES organizations(id),
  created_by UUID NOT NULL REFERENCES users(id)
);

---

Part VIII — Two practical lenses

27. Building an Auth Server from scratch

You can now see what an Authorization Server actually is: a service that authenticates users, manages identities, and issues signed tokens. A from-scratch build (OAuth 2.0 + OIDC core) needs the following components.

flowchart TD
    subgraph Endpoints
      AZ["/authorize<br/>validate client, show login+consent,<br/>issue auth code"]
      TK["/token<br/>exchange code+secret / PKCE verifier<br/>issue JWT + refresh token"]
      UI["/userinfo OIDC"]
      WK[".well-known/openid-configuration<br/>+ jwks.json"]
      RV["/revoke, /introspect"]
    end
    subgraph Stores
      US[(User store<br/>credentials hashed: argon2/bcrypt)]
      CS[(Client registry<br/>client_id, secret, redirect_uris, scopes)]
      AC[(Auth-code store<br/>short-lived, single-use)]
      RT[(Refresh-token store<br/>rotation + family tracking)]
      KS[(Signing keys<br/>RS256/ES256 keypair, rotation)]
    end
    AZ --> AC
    TK --> RT
    TK --> KS
    WK --> KS

Build checklist:

  1. User store — credentials hashed with argon2 or bcrypt; never reversible.
  2. Client registry — each client's client_id, client_secret (confidential clients), allowed redirect_uris, and permitted scopes. Validate redirect_uri by exact string match (RFC 9700 makes this a MUST; only the port of a localhost URI may vary) — this is a critical anti-phishing control.
  3. /authorize endpoint — validate the client and redirect URI, render login + consent, then issue a short-lived authorization code bound to the client, redirect URI, and (for PKCE) the code_challenge.
  4. /token endpoint — exchange the code for tokens after verifying the client_secret (confidential) or the PKCE code_verifier (public). Support grant_type values: authorization_code, refresh_token, client_credentials.
  5. Signing keys — an RS256/ES256 key pair. Sign JWTs with the private key; publish the public key at /.well-known/jwks.json. Support key rotation (multiple active keys, each with a kid).
  6. Discovery — serve /.well-known/openid-configuration so Resource Servers and clients can auto-configure.
  7. Refresh tokens — store them, implement rotation and family revocation (§12).
  8. /userinfo (OIDC) and an id_token if you support authentication, not just authorization.
  9. Revocation / introspection — /revoke and /introspect endpoints for opaque-token or revocation-list scenarios.
  10. Federation — upstream IdP connectors so you can act as an identity broker (§18), consuming external id_tokens and issuing your own.
  11. Claims enrichment — a hook point that runs before issuance to inject your roles, tenantId, org_id (§17).
  12. Admin API — programmatic user/role/group management, secured by a service account (Client Credentials).
  13. Groups & roles model — the categorization + capability structure of Part VII.

The honest takeaway: building a correct, secure Authorization Server is a large undertaking — key management, redirect-URI validation, token rotation, and timing-safe checks are each easy to get subtly wrong. This is precisely why most teams adopt a mature server like Keycloak rather than building one. The value of understanding the components above is in being able to operate and extend such a server competently.

28. How Keycloak maps to every concept

Every concept in this guide has a direct Keycloak feature. This table is the bridge between the theory and the product.

Concept (this guide) Keycloak feature
Authorization Server (§2) The Keycloak server itself
Isolation boundary / user pool (§24) Realm
A client app / entry point (§3, §24) Client (confidential or public)
client_secret, service account (§6, §17) Confidential client + Service Accounts
Scopes (§15) Client scopes
Custom claims / enrichment (§17) Protocol Mappers (+ Script Mapper / Java SPI)
Coarse roles (§15) Realm roles & client roles
Group categorization + org (§24) Groups & sub-groups (with role mappings)
External SSO / identity broker (§18) Identity Providers (OIDC/SAML brokering)
JIT provisioning (§19) First-broker-login flow + mappers
Per-group login branding (§25) Themes (per client)
Signing keys + JWKS (§11, §27) Realm keys + built-in JWKS endpoint
Discovery (§27) /.well-known/openid-configuration (built-in)
Token rotation / lifetimes (§4, §12) Realm settings → Tokens: lifespans + Revoke Refresh Token (rotation, off by default)
Email-change / event sync (§23) Event Listener SPI (no built-in webhooks: you deploy a listener that posts to your app)
Multi-tenant orgs (§24) Organizations (Keycloak 26+)
Admin user/role management (below) Admin REST API

Admin operations through your app (not the Keycloak console)

To let an admin user create users or assign roles from your client application, use a service account on the BFF. The BFF verifies the requesting user's admin role, then uses its own service-account token (Client Credentials flow) to call Keycloak's Admin REST API. End users never touch Keycloak directly.

sequenceDiagram
    autonumber
    participant U as Admin user client app
    participant B as Nuxt BFF
    participant K as Keycloak Admin API
    participant T as Token cache

    U->>B: POST /api/admin/users + session cookie
    Note over B: verify token has admin role
    B->>T: cached service-account token?
    alt cache miss
        B->>K: client_credentials to token
        K-->>B: service-account token
        B->>T: cache until exp
    end
    B->>K: POST /admin/realms/realm/users<br/>Bearer service token
    K-->>B: 201 Created + Location /users/id
    B-->>U: 201 userId, email, roles
    Note over B: Audit log actor = admin user sub

Service-account setup (once): create a confidential client (my-app-bff), enable Service Accounts, and assign only the needed realm-management roles — manage-users, view-users, query-users, and (carefully) manage-realm. Never assign realm-admin — least privilege applies here too.

Token caching — the service-account token is cacheable until expiry; don't fetch a new one per request:

let cached: { value: string; expiresAt: number } | null = null
async function getServiceAccountToken() {
  if (cached && Date.now() < cached.expiresAt - 10_000) return cached.value
  const res = await fetch(`${KC_URL}/realms/${REALM}/protocol/openid-connect/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: KC_CLIENT_ID, client_secret: KC_CLIENT_SECRET,
    }),
  })
  const { access_token, expires_in } = await res.json()
  cached = { value: access_token, expiresAt: Date.now() + expires_in * 1000 }
  return access_token
}

The audit-log caveat: Keycloak's own event log records these actions as performed by the service account, not the human admin. You must keep your own audit log in the BFF, where you have the real admin's sub (event.context.user.sub). This is a fundamental limitation of the service-account pattern — design for it in your data model.

(An alternative worth knowing: Keycloak 26.2+ supports standard Token Exchange, RFC 8693, so a BFF can exchange the admin user's own token for one aimed at another audience and call as that user. For the Admin API the service account remains the simple, common choice.)

Useful Admin API endpoints:

Users
POST   /admin/realms/{realm}/users                 create
GET    /admin/realms/{realm}/users?search=wim      search
PUT    /admin/realms/{realm}/users/{id}            update
DELETE /admin/realms/{realm}/users/{id}            delete
PUT    /admin/realms/{realm}/users/{id}/reset-password

Roles
GET    /admin/realms/{realm}/roles
POST   /admin/realms/{realm}/users/{id}/role-mappings/realm   assign
DELETE /admin/realms/{realm}/users/{id}/role-mappings/realm   remove

Sessions
GET    /admin/realms/{realm}/users/{id}/sessions             list
POST   /admin/realms/{realm}/users/{id}/logout               force logout (all sessions)
DELETE /admin/realms/{realm}/sessions/{session}              end one session

The official @keycloak/keycloak-admin-client npm package wraps all of this in a typed client — preferable to hand-rolled fetch boilerplate in production. With a service account you call kcAdminClient.auth({ grantType: 'client_credentials', … }) and call it again before the token expires (the README's pattern is a timer); don't assume it re-authenticates for you.

29. The modern baseline

Everything above follows the current best practice. The documents that define it:

Document Status (Oct 2026) What it changes
RFC 6749 OAuth 2.0 (2012) Standard The original framework: roles, grants, endpoints.
RFC 7636 PKCE (2015) Standard code_verifier / code_challenge (§5).
RFC 9700 OAuth 2.0 Security BCP (Jan 2025) Best Current Practice PKCE for public clients MUST; Implicit SHOULD NOT; password grant MUST NOT; exact redirect-URI matching; refresh tokens of public clients rotated or sender-constrained.
OAuth 2.1 (draft-ietf-oauth-v2-1, rev. 16) Draft, IESG submission planned for end of 2026 Folds 6749 + 6750 + the BCP into one spec: no Implicit, no password grant, PKCE required for the code flow, no bearer tokens in query strings.
OAuth 2.0 for Browser-Based Apps Approved, in the RFC Editor queue BFF > token-mediating backend > browser-only client (§13).
RFC 9449 DPoP (2023) Standard Sender-constrained tokens: the client proves possession of a private key on every call, so a stolen token alone is useless.
RFC 9126 PAR (2021) Standard Pushed Authorization Requests: the client POSTs the authorization parameters on the back-channel and puts only a request_uri in the browser URL.
RFC 9068 JWT access tokens (2021) Standard Standard claims and typ: at+jwt for JWT access tokens (§10).
RFC 8693 Token Exchange (2020) Standard Swap one token for another (audience change, delegation).

If you remember one thing: Authorization Code + PKCE everywhere, tokens off the browser where possible, and validate alg, signature, exp, iss, aud on every request.

---

Verification notes (2026-10-05)

This guide began as an AI sparring session. On 2026-10-05 it was checked against the RFCs, the IETF datatracker, the Keycloak server-admin guide and the Keycloak Admin REST API spec. Changes:

  • §4 Authorization-code lifetime: RFC 6749 caps it at 10 minutes (was "~30–60 s"); a leaked code is also useless without the PKCE verifier. Keycloak defaults added.
  • §5 PKCE: challenge is BASE64URL(SHA256(verifier)) with S256; PKCE binds the code to the flow, it does not authenticate the client; RFC 9700 wording (MUST for public, RECOMMENDED for confidential).
  • §8 Implicit: fragments never reach proxy or server logs (removed); added fragment re-attachment on redirects and token injection; RFC 9700 says SHOULD NOT (not "formally deprecated"); added the password grant's MUST NOT.
  • §9 OIDC: added openid scope, id_token audience, nonce.
  • §10 JWT: encoding is not encryption; RFC 9068; opaque tokens + introspection.
  • §11 Validation: JWKS found via discovery jwks_uri (the path is not fixed); kid refetch; alg allowlist; nbf; 401 vs 403 per RFC 6750.
  • §12–13 Storage/BFF: SameSite=Strict breaks the login callback; __Host- prefix; CSRF on the BFF; "pure SPA with HttpOnly cookies" corrected (JavaScript cannot set HttpOnly cookies) to in-memory tokens + rotated or DPoP-bound refresh tokens; RFC 9700 refresh-token rule.
  • §17 Keycloak Script Mapper requires a deployed JAR since Keycloak 18; Auth0 namespacing is now optional.
  • §20 "Recycled addresses" was mislabelled as pre-hijacking; key is (issuer, sub); Entra pairwise sub → tid + oid; nOAuth.
  • §21 Pre-hijacking defined correctly; Keycloak first-broker-login behaviour added.
  • §22 Keycloak User Profile (24+).
  • §24 The "Keycloak Action" code was Auth0 syntax: replaced with the Group Membership mapper + API-side parsing; added Keycloak Organizations.
  • §25 Per-client IdP restriction needs a flow override, kc_idp_hint or Organizations.
  • §28 Force logout is POST /users/{id}/logout (was DELETE /users/{id}/sessions); the admin client does not re-authenticate by itself; Keycloak has no built-in webhooks; refresh rotation is the Revoke Refresh Token setting; Token Exchange note.
  • §29 added.

References

  • RFC 6749 — The OAuth 2.0 Authorization Framework: https://www.rfc-editor.org/rfc/rfc6749
  • RFC 6750 — Bearer Token Usage: https://www.rfc-editor.org/rfc/rfc6750
  • RFC 7636 — PKCE: https://www.rfc-editor.org/rfc/rfc7636
  • RFC 8628 — Device Authorization Grant: https://www.rfc-editor.org/rfc/rfc8628
  • RFC 9068 — JWT Profile for Access Tokens: https://www.rfc-editor.org/rfc/rfc9068
  • RFC 9449 — DPoP: https://www.rfc-editor.org/rfc/rfc9449
  • RFC 9700 — OAuth 2.0 Security Best Current Practice: https://www.rfc-editor.org/rfc/rfc9700
  • OAuth 2.1 draft: https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/
  • OAuth 2.0 for Browser-Based Applications: https://datatracker.ietf.org/doc/draft-ietf-oauth-browser-based-apps/
  • OpenID Connect Core 1.0: https://openid.net/specs/openid-connect-core-1_0.html
  • OWASP API Security Top 10 (2023), API1 BOLA: https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/
  • Account pre-hijacking (Sudhodanan & Paverd, 2022): https://www.helpnetsecurity.com/2022/05/24/account-pre-hijacking/
  • nOAuth (Semperis, 2025 follow-up): https://www.semperis.com/blog/noauth-abuse-alert-full-account-takeover/
  • Keycloak Server Administration Guide (Organizations, first broker login, token settings): https://www.keycloak.org/docs/latest/server_admin/index.html
  • Keycloak Admin REST API: https://www.keycloak.org/docs-api/latest/rest-api/index.html
  • Keycloak 26.2 Standard Token Exchange: https://www.keycloak.org/2025/05/standard-token-exchange-kc-26-2

---

Appendix — Glossary

Term Meaning
Access token Short-lived credential proving authorization to call the Resource Server.
Audience (aud) The API a token is intended for.
Authorization code Short-lived, single-use code exchanged for tokens on the back-channel.
Back-channel Direct server-to-server communication, invisible to the browser.
BFF Backend-for-Frontend; a server layer that holds tokens so the browser never does.
BOLA Broken Object Level Authorization; missing per-record ownership checks (OWASP API #1).
Claim A field inside a JWT payload.
Client The application requesting access on the user's behalf.
DPoP Demonstrating Proof of Possession (RFC 9449); binds a token to a key the client holds.
Identity broker An Auth Server that federates external IdPs and issues its own tokens.
Issuer (iss) The Auth Server that minted a token.
JWKS JSON Web Key Set; the public keys used to verify JWT signatures.
JWT JSON Web Token; a signed, self-contained token.
OIDC OpenID Connect; an identity layer on top of OAuth 2.0.
PKCE Proof Key for Code Exchange; secures public clients without a static secret.
Realm Keycloak's isolation boundary (users, clients, roles).
Refresh token Long-lived credential used to obtain new access tokens.
Resource Owner The user who owns the data and grants access.
Resource Server The API holding protected data; validates tokens and owns authorization.
Scope A named permission requested by a client.
Service account A machine identity (Client Credentials) used for non-interactive calls.
sub Subject; the stable, unique user identifier and the system's identity anchor.
  • dotnet-microservices-auth-architecture — one concrete implementation of the build branch §27 sets out, on .NET Identity Core: token generation, consumer service configuration, and the authorization patterns §15's three layers describe.
  • seven-cryptography-concepts — the primitives §10 and §11 rest on. Asymmetric signing is what makes a JWT verifiable without a database lookup, and hashing is what makes PKCE (§5) work without a static client secret.

End of guide.