Skip to content

Secure Authentication & Authorization

An OAuth 2.0, OpenID Connect & Keycloak Architecture Guide

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


Table of contents

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

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

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

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

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

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

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

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

---

Part I — Foundations

1. Why OAuth 2.0 exists

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

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

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

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

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


2. The actors and the vocabulary

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

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

A few more terms used throughout:

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

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


3. The Authorization Code flow

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

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

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

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

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

Walking through the nine steps:

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

4. Token types and the back-channel

Why the intermediate "code"?

You might ask why the Auth Server doesn't just hand over a token directly in step 5. The answer is the back-channel. Tokens are valuable; you never want them in a browser URL, which is logged in history, proxies, and analytics. The code is worthless without the client_secret, 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 Referer header (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 /userinfo endpoint.

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

---

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:

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

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 jti values) and check it as an extra step. This reintroduces a per-request lookup, but only when revocation is a hard requirement.

12. Where to store tokens

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

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

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

Two patterns:

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

Refresh-token specifics (regardless of pattern):

  • If stored client-side, give it its own HttpOnly; Secure; SameSite=Strict cookie, scoped via Path=/auth/refresh so it is sent only to the refresh endpoint.
  • Implement refresh-token rotation: every use invalidates the old token and issues a new one. A replayed old token signals theft → revoke the whole token family.
  • 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.

  • Authenticationwho is this? → the sub claim.
  • Authorizationwhat 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/123Layer 1 blocks (no write:invoices scope). GET /admin/usersLayer 2 blocks (no admin role). GET /orders/789 belonging to tenant B → Layers 1 & 2 pass; only Layer 3 catches it, by checking order.tenantId === token.tenantId.

16. The dynamic-scopes problem

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

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

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

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

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

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

17. Token enrichment hooks

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

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

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

Auth0 — Actions (post-login):

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

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.com at Google and at Microsoft are the same human.
  • Risk 3 — email_verified is not always true. Never trust an unverified email as an identity anchor; always check the claim.

The correct key is the composite (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:

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

Auto-merging on email skips 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_sub directly — always to your internal users.id. Only one column knows Keycloak exists.

Why not just extend Keycloak? Keycloak does support custom user attributes, 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_id for 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:

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

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

28. How Keycloak maps to every concept

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

Concept (this guide) Keycloak feature
Authorization Server (§2) The Keycloak server itself
Isolation boundary / user pool (§24) Realm
A client app / entry point (§3, §24) Client (confidential or public)
client_secret, service account (§6, §17) Confidential client + Service Accounts
Scopes (§15) Client scopes
Custom claims / enrichment (§17) Protocol Mappers (+ Script Mapper / Java SPI)
Coarse roles (§15) Realm roles & client roles
Group categorization + org (§24) Groups & sub-groups (with role mappings)
External SSO / identity broker (§18) Identity Providers (OIDC/SAML brokering)
JIT provisioning (§19) First-broker-login flow + mappers
Per-group login branding (§25) Themes (per client)
Signing keys + JWKS (§11, §27) Realm keys + built-in JWKS endpoint
Discovery (§27) /.well-known/openid-configuration (built-in)
Token rotation / lifetimes (§4, §12) Realm 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.