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:
- Sign in — the user clicks a button. Nothing sensitive has moved yet.
- Authorization request — the client redirects the browser to the Auth Server's
/authorizeendpoint, carrying itsclient_id, aredirect_uri, the requestedscope,response_type=code, and a randomstatevalue (CSRF protection; the client checks it when the browser comes back). Modern clients also send a PKCEcode_challengehere (§5). - Login & consent — the Auth Server serves its own login UI. The client never sees the credentials. The user also approves the requested scopes.
- 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.
- Code redirect — the Auth Server redirects the browser back to the client's
redirect_uriwith the code in the URL. The code is useless on its own. - Code exchange (back-channel) — the client's server makes a direct HTTPS call to the
/tokenendpoint, sending the code plus itsclient_secret. This never touches the browser. - Tokens issued — the Auth Server validates the code and secret, then returns an access token and a refresh token.
- API call — the client calls the Resource Server, attaching the access token as a
Bearercredential. - 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
Locationheader that has none, so an open redirector on the client's domain can forward the token to an attacker. - The
Refererheader, 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/userinfoendpoint.
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
openidin thescope(scope=openid profile email). - The
id_tokenis for the client, itsaudis the client's ownclient_id. Never send it to an API as a bearer token; that is what the access token is for. - The client sends a random
noncein the authorization request and checks that theid_tokencarries the same value, which stops a stolenid_tokenfrom 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 claimsiss,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:
issensures 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.audensures 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
jtivalues) 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:
HttpOnlystops 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.StrictvsLax. AStrictcookie 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 (thestate/ PKCE verifier of a login in progress) needLax.SameSitealso 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 andPath=/.
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=Strictcookie, scoped viaPath=/auth/refreshso 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
subclaim. - 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 (nowrite:invoicesscope).GET /admin/users→ Layer 2 blocks (noadminrole).GET /orders/789belonging to tenant B → Layers 1 & 2 pass; only Layer 3 catches it, by checkingorder.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.comat 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 theemailclaim. - Risk 3 —
email_verifiedis 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:
- User is logged in via Google → authenticated as
user_1. - In account settings, clicks "Connect Microsoft account."
- Completes the Microsoft login flow.
- Because they were already authenticated, the system links
(microsoft, 9f3d22)touser_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_subdirectly — always to your internalusers.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_idfor 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:
- User store — credentials hashed with argon2 or bcrypt; never reversible.
- Client registry — each client's
client_id,client_secret(confidential clients), allowedredirect_uris, and permitted scopes. Validateredirect_uriby exact string match (RFC 9700 makes this a MUST; only the port of alocalhostURI may vary) — this is a critical anti-phishing control. /authorizeendpoint — 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) thecode_challenge./tokenendpoint — exchange the code for tokens after verifying theclient_secret(confidential) or the PKCEcode_verifier(public). Supportgrant_typevalues:authorization_code,refresh_token,client_credentials.- 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 akid). - Discovery — serve
/.well-known/openid-configurationso Resource Servers and clients can auto-configure. - Refresh tokens — store them, implement rotation and family revocation (§12).
/userinfo(OIDC) and anid_tokenif you support authentication, not just authorization.- Revocation / introspection —
/revokeand/introspectendpoints for opaque-token or revocation-list scenarios. - Federation — upstream IdP connectors so you can act as an identity broker (§18),
consuming external
id_tokens and issuing your own. - Claims enrichment — a hook point that runs before issuance to inject your roles,
tenantId,org_id(§17). - Admin API — programmatic user/role/group management, secured by a service account (Client Credentials).
- 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))withS256; 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
openidscope,id_tokenaudience,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);kidrefetch;algallowlist;nbf; 401 vs 403 per RFC 6750. - §12–13 Storage/BFF:
SameSite=Strictbreaks 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 pairwisesub→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_hintor Organizations. - §28 Force logout is
POST /users/{id}/logout(wasDELETE /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. |
Related¶
- 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.