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
---¶
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). - 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, 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 | ~30–60 seconds, single-use | 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.
---¶
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 random code_verifier, 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 = SHA256(code_verifier)
C->>A: /authorize + code_challenge
A-->>C: redirect with ?code=abc123
C->>A: /token + code + code_verifier
Note over A: Verify SHA256(code_verifier)<br/>equals stored code_challenge
A-->>C: access_token + refresh_token
PKCE is now recommended for all clients, confidential ones included — it adds defense in depth at negligible cost. It 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 (smart TVs, CLI tools, IoT). The device shows a short code; the user approves it on a phone or laptop; the device polls 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. 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).
- The
Refererheader (leaked to every third-party resource the page loads). - Browser extensions, which can read the URL.
- Proxy and server access logs.
window.location, readable by any script on the page — including an injected ad or an XSS payload.
And critically, the Implicit flow has no client_secret, so the Auth Server cannot verify
that a token request truly came from your app. 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) formally deprecated the Implicit flow. 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.
flowchart LR
A[Implicit flow] -->|token in URL fragment| B[Exposed in history, logs,<br/>Referer, JS, extensions]
A -->|no client_secret| C[Server can't verify origin]
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 proves origin| 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.
---¶
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.
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, which it fetches once from a well-known endpoint and caches:
GET https://auth.yourapp.com/.well-known/jwks.json
Validation is a strict, ordered sequence — every step must pass:
flowchart TD
A[Parse JWT into<br/>header, payload, signature] --> B[Verify signature<br/>with public key from JWKS]
B --> C[Check exp:<br/>token not expired]
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]
B -.fail.-> X[401 Unauthorized]
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.
A minimal middleware (Nuxt / Nitro, using jose):
// server/middleware/auth.ts
import { jwtVerify, createRemoteJWKSet } from 'jose'
const JWKS = createRemoteJWKSet(
new URL('https://auth.yourapp.com/.well-known/jwks.json')
)
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',
})
event.context.user = payload // verified, trusted identity
})
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 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.
- 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 (HttpOnly; Secure; SameSite=Strict),
meaningless to an attacker without access to the server's session store.
Practical recommendation: if you have any server layer (Nuxt's server/ gives you one for
free), use the BFF. If you are building a pure SPA with no server at all, properly configured
HttpOnly cookies + PKCE are an acceptable second-best — far better than localStorage.
---¶
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[401 / 403]
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)
}
Keycloak — Protocol Mappers (per client) sync roles/attributes into the token; for custom logic you add a Script Mapper or a Java SPI. 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 / Script Mapper / Java SPI | Admin console / 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:
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. This is the account pre-hijacking vector.
- Risk 2 — same email ≠ same person across providers. No global authority guarantees that
wim@example.comat Google and at Microsoft are the same human. - 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 (provider, sub). Every OIDC provider guarantees sub is
stable and unique within that provider; it never changes even if the user changes their email.
-- 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 step 4 — exactly the pre-hijacking vulnerability: an attacker registers your email with a second provider before you do, and the system hands them your account.
---¶
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, but they are flat key-value strings with no relations, poor 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 broken cross-group token validation. 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, injected by an enrichment hook /
protocol mapper:
// Keycloak Action: parse group paths into user_type + org_id
const groups = event.user.groups ?? [] // e.g. ['/customers/org_abc', '/customers']
let userType = 'unknown', orgId = null
for (const path of groups) {
const parts = path.split('/').filter(Boolean) // ['customers','org_abc']
if (parts[0]) userType = parts[0]
if (parts[1]) orgId = parts[1]
}
api.accessToken.setCustomClaim('https://api.yourapp.com/user_type', userType)
api.accessToken.setCustomClaim('https://api.yourapp.com/org_id', orgId)
Resulting token:
{
"sub": "user_123",
"https://api.yourapp.com/user_type": "customer",
"https://api.yourapp.com/org_id": "org_abc",
"realm_access": { "roles": ["order_viewer", "invoice_viewer"] },
"scope": "read:orders read:invoices"
}
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 theme, redirect URIs, IdP restrictions, default scopes — while sharing one user pool, one set of groups, and one enrichment logic. Best of both worlds.
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
export function allowUserTypes(...types: string[]) {
return (event: H3Event) => {
const ut = event.context.user['https://api.yourapp.com/user_type']
if (!types.includes(ut)) throw createError({ statusCode: 403 })
}
}
export function scopeToOrg(event: H3Event) {
const ut = event.context.user['https://api.yourapp.com/user_type']
const org = event.context.user['https://api.yourapp.com/org_id']
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_uriexactly — 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 token settings + refresh rotation |
| Email-change / event sync (§23) | Events SPI / event listeners (webhooks) |
| 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.
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
DELETE /admin/realms/{realm}/users/{id}/sessions force logout
The official @keycloak/keycloak-admin-client npm package wraps all of this in a typed
client and handles token re-authentication internally — preferable to hand-rolled fetch
boilerplate in production.
---¶
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. |
| 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. |
End of guide.