- Published on
Keycloak Integration Hands-On — Run It with Docker, Configure Realm/Client, Wire Up Spring Boot and Next.js (2025 Hands-On Guide)
- Authors

- Name
- Youngju Kim
- @fjvbn20031
Prologue — Don't Build Authentication Yourself
The most common mistake on a new project: implementing login yourself. Password hashing, sessions, token issuance, password reset, social login, 2FA, brute-force defense... building all of that yourself is the same as digging your own security holes.
Keycloak is an open-source IAM (Identity and Access Management) sponsored by Red Hat. It implements OIDC, OAuth2, and SAML as standards, and it ships with an admin console, social login, 2FA, LDAP integration, and brute-force defense. It is self-hosted, and the license is Apache 2.0.
A 2025 note: starting with version 17, Keycloak was rewritten on Quarkus. The old WildFly distribution is gone. This article targets Keycloak 26.x.
This article is not a theory book — it is a hands-on. Deep coverage of the OAuth2/OIDC protocols themselves lives in a separate article (the OAuth2/OIDC deep-dive guide); here we focus on actually running it and wiring it up. Open a terminal and follow along.
The flow is this: run it with Docker → configure Realm/Client/User → poke the OIDC endpoints directly → wire up Spring Boot / Node.js backends → wire up the Next.js frontend → recap the token flow → production checklist → troubleshooting.
Chapter 0 · Prerequisites and the Big Picture
Prerequisites
- Docker (to run Keycloak)
- JDK 21+ (for the Spring Boot lab) or Node.js 20+ (for the Node/Next.js lab)
curl,jq(for poking endpoints)
The Big Picture
┌─────────┐ 1. Login redirect ┌──────────────┐
│ Browser │ ───────────────────────▶ │ Keycloak │
│ │ ◀─────────────────────── │ (IdP, 8080) │
└────┬────┘ 2. Auth code + tokens └──────┬───────┘
│ │
│ 3. Call API with Access Token │ JWKS public key
▼ ▼
┌─────────┐ ┌──────────────┐
│ Backend │ ─── 4. Verify token sig ─▶│ (JWKS cache) │
│ (API) │ └──────────────┘
└─────────┘
The key point: the backend does not ask Keycloak on every request. It fetches Keycloak's public keys (JWKS) once and caches them, then verifies the JWT signature locally with those keys. Even if Keycloak goes down, already-issued tokens still verify.
7 Core Keycloak Concepts
| Concept | One-line description |
|---|---|
| Realm | Isolation boundary. An independent namespace for users, clients, and roles. Usually one per service/environment |
| Client | An application registered with Keycloak. A frontend SPA and a backend API are each a Client |
| User | An end-user account. Belongs to a Realm |
| Role | A permission bundle. There are Realm Roles (global) and Client Roles (per-app) |
| Group | A bundle of Users. Map a Role to a Group and member Users inherit it |
| Client Scope | A reusable unit that decides which claims/roles go into a token |
| Identity Provider | Brokering that connects an external IdP (Google, GitHub, another Keycloak) |
Chapter 1 · Running Keycloak (Docker)
30-Second Quick Start (dev mode)
docker run --name keycloak -p 8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.0 start-dev
Version caveat: starting with Keycloak 26, the initial admin account environment variables changed to
KC_BOOTSTRAP_ADMIN_USERNAME/KC_BOOTSTRAP_ADMIN_PASSWORD. Earlier versions useKEYCLOAK_ADMIN/KEYCLOAK_ADMIN_PASSWORD.
Open http://localhost:8080 in a browser → "Administration Console" → log in with admin / admin. Done.
start-dev uses an in-memory H2 DB and does not force HTTPS. It is for labs and local use only. Production is covered in Chapter 10.
docker-compose — A Realistic Setup with Postgres Attached
Even for a lab, it is annoying when your data disappears. Attach Postgres.
# docker-compose.yml
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak"]
interval: 5s
timeout: 5s
retries: 5
keycloak:
image: quay.io/keycloak/keycloak:26.0
command: start-dev
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
volumes:
pg_data:
docker compose up -d
docker compose logs -f keycloak # ready once "Keycloak ... started" appears
dev Mode vs Production Mode
start-dev | start | |
|---|---|---|
| HTTPS | Optional (not forced) | Required by default |
| Cache | Local | Distributed (Infinispan) |
| hostname | Auto-inferred | Must be explicit (KC_HOSTNAME) |
| Purpose | Local / labs | Production |
Health and Metrics Endpoints
When you turn it on with KC_HEALTH_ENABLED=true — Keycloak exposes these on the management port (9000 by default):
http://localhost:9000/health/ready— readiness statehttp://localhost:9000/health/live— liveness statehttp://localhost:9000/metrics— Prometheus metrics
Chapter 2 · Creating a Realm
A Realm is an isolation boundary. The master realm is for managing Keycloak itself, so leave it alone and create a new realm for our service.
Creating It in the Console
- Realm dropdown at the top left → Create realm
- Realm name:
demo - Create
From now on, all work happens inside the demo realm.
Realm Settings Worth Knowing (Realm settings)
- Tokens tab
Access Token Lifespan— 5 minutes by default. Shorter is safer, longer is more convenient. 5–15 minutes is typical.SSO Session Idle / Max— governs the refresh token lifetime.
- Login tab
User registration— whether self-service signup is allowedForgot password,Remember me,Email as username
- Sessions tab — fine-tune session lifetimes
- Security defenses tab — turn on
Brute Force Detection(Chapter 10)
For now, leave these at their defaults and continue.
Chapter 3 · Configuring Clients — Two Types
A Client is "an app registered with Keycloak." Our lab creates two:
demo-frontend— Public Client (Next.js SPA). No secret, uses PKCE.demo-backend— Confidential Client (API server). Has a secret.
Public Client — For the Frontend
Left side Clients → Create client:
- General settings
- Client type:
OpenID Connect - Client ID:
demo-frontend
- Client type:
- Capability config
Client authentication: Off (← this is the key to making it Public)Standard flow: On (Authorization Code Flow)Direct access grants: Off (turn this off in production)
- Login settings
Valid redirect URIs:http://localhost:3000/*Valid post logout redirect URIs:http://localhost:3000/*Web origins:http://localhost:3000(← the allowed CORS origin)
A Public Client cannot keep a secret safely (all browser code is visible), so PKCE is mandatory. Keycloak supports PKCE automatically in Standard flow.
Confidential Client — For the Backend
Create client again:
- Client ID:
demo-backend - Capability config
Client authentication: On (← Confidential)Standard flow: OnService accounts roles: On (for server-to-server communication — Client Credentials Grant)
- After creation, copy the
Client Secretfrom the Credentials tab.
If the backend is a pure "Resource Server" (verification only), it actually does not need a secret. It only needs to verify with JWKS. The secret is used when the backend needs to obtain tokens itself (Service Account, Token Exchange).
Summary of Important Client Settings
| Setting | Meaning | Common mistake |
|---|---|---|
| Valid redirect URIs | Whitelist of URLs allowed to return to after login | Too broad with * → open redirect risk |
| Web origins | Allowed CORS origins | Leaving it empty blocks browser calls with CORS |
| Standard flow | Enables Authorization Code Flow | Required for SPAs and web apps |
| Direct access grants | Direct username/password exchange | Turn it off in production (test only) |
| Service accounts | Client Credentials Grant | Server-to-server only |
Chapter 4 · Creating Users, Roles, and Groups
Creating a User
Users → Create new user:
- Username:
alice - Email:
alice@demo.test,Email verified: On - Create → Credentials tab → Set password →
alice123,Temporary: Off
(Temporary: On forces a password change on first login. For the lab, set it to Off.)
Creating Realm Roles
Realm roles → Create role:
- Create one
adminand oneuser.
Mapping a Role to a User
Users → alice → Role mapping → Assign role → select admin and user.
Bundling with Groups (Optional but Recommended)
When you have many users, attaching a Role per User is hell. Use Groups.
- Groups → Create group →
administrators - That group's Role mapping → assign
admin - Users →
alice→ Groups → Join group →administrators
Now anyone who joins the administrators group automatically gets the admin role.
How Roles End Up in the Token
A Realm Role goes, by default, into the Access Token's realm_access.roles array. A Client Role goes into the per-client roles array under the resource_access object. This is handled by the default Mapper of the roles Client Scope.
If a role does not show up in the token — check whether the roles scope is attached as Default in the Client's Client scopes tab (see Chapter 11, Troubleshooting).
Chapter 5 · Understanding the OIDC Endpoints — Poke Them Directly with curl
Before writing integration code, see for yourself what Keycloak hands out. This is the heart of the hands-on.
The Discovery Document — A Map to Everything
curl -s http://localhost:8080/realms/demo/.well-known/openid-configuration | jq
This is where all the key endpoints appear:
| Endpoint | URL (realm=demo) | Role |
|---|---|---|
| issuer | http://localhost:8080/realms/demo | Token issuer identifier |
| authorization_endpoint | .../protocol/openid-connect/auth | Where you send the user for the login screen |
| token_endpoint | .../protocol/openid-connect/token | Exchange a code for tokens |
| userinfo_endpoint | .../protocol/openid-connect/userinfo | Look up user info |
| jwks_uri | .../protocol/openid-connect/certs | Public keys for signature verification |
| end_session_endpoint | .../protocol/openid-connect/logout | Logout |
Getting a Token for Testing (Password Grant)
Warning: the Password Grant (Direct Access Grants) is for testing only. A real app uses the Authorization Code Flow. Here we turn it on temporarily just to quickly see token contents — flip the
demo-backendClient'sDirect access grantsto On for a moment.
curl -s -X POST \
http://localhost:8080/realms/demo/protocol/openid-connect/token \
-d "client_id=demo-backend" \
-d "client_secret=PASTE_YOUR_SECRET" \
-d "grant_type=password" \
-d "username=alice" \
-d "password=alice123" | jq
Response:
{
"access_token": "eyJhbGciOiJSUzI1Ni...",
"expires_in": 300,
"refresh_token": "eyJhbGciOiJIUzI1Ni...",
"token_type": "Bearer",
"scope": "profile email"
}
What's Inside the JWT
Decode the middle part (the payload) of the access_token:
TOKEN="eyJhbGci..." # the access_token from above
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq
The key claims:
{
"iss": "http://localhost:8080/realms/demo",
"sub": "f7e3...-user-uuid",
"aud": "account",
"exp": 1763400000,
"iat": 1763399700,
"azp": "demo-backend",
"realm_access": { "roles": ["admin", "user", "default-roles-demo"] },
"resource_access": { "account": { "roles": ["view-profile"] } },
"scope": "profile email",
"email": "alice@demo.test",
"preferred_username": "alice"
}
What the backend will verify: iss (is it our issuer), exp (not expired), aud (is the token meant for me), and the signature (with the JWKS public key). Authorization decisions are made from realm_access.roles.
Now let's move it into code.
Chapter 6 · Backend Integration (1) — Spring Boot Resource Server
In Spring Boot, a Keycloak-specific adapter is no longer needed. (The old keycloak-spring-boot-adapter is deprecated.) Use the standard Spring Security OAuth2 Resource Server.
Dependencies
// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
}
application.yml — This Is Almost Everything
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/demo
Give it just the issuer-uri and Spring automatically reads .well-known/openid-configuration, finds the jwks_uri, and caches the public keys. Token signature, iss, and exp verification turn on automatically.
SecurityConfig — Converting Keycloak Roles to Spring Authorities
Out of the box, Spring does not understand realm_access.roles. Plug in a converter.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // enables @PreAuthorize
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("admin")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(converter())))
.sessionManagement(s -> s
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable()); // stateless API, so CSRF is unnecessary
return http.build();
}
// map realm_access.roles -> ROLE_* authorities
private JwtAuthenticationConverter converter() {
JwtAuthenticationConverter c = new JwtAuthenticationConverter();
c.setJwtGrantedAuthoritiesConverter(jwt -> {
Map<String, Object> realm = jwt.getClaim("realm_access");
if (realm == null || realm.get("roles") == null) {
return List.of();
}
Collection<String> roles = (Collection<String>) realm.get("roles");
return roles.stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.collect(Collectors.toList());
});
return c;
}
}
hasRole("admin") internally looks for the ROLE_admin authority. That is why the converter prefixes with ROLE_.
Protected Endpoints
@RestController
@RequestMapping("/api")
public class DemoController {
@GetMapping("/public/ping")
public String publicPing() {
return "anyone can see this";
}
@GetMapping("/me")
public Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) {
return Map.of(
"sub", jwt.getSubject(),
"username", jwt.getClaimAsString("preferred_username"));
}
@GetMapping("/admin/secret")
@PreAuthorize("hasRole('admin')")
public String adminSecret() {
return "only admins";
}
}
Testing
# with the token from Chapter 5
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/me
# without a token → 401
curl -i http://localhost:8081/api/me
Chapter 7 · Backend Integration (2) — Node.js / Express
The Node side is the same — local verification with JWKS. Auth0's express-oauth2-jwt-bearer is the simplest.
Install & Middleware
npm install express express-oauth2-jwt-bearer
import express from 'express'
import { auth } from 'express-oauth2-jwt-bearer'
const app = express()
// auto JWKS fetch + cache; verifies iss, exp, signature
const checkJwt = auth({
issuerBaseURL: 'http://localhost:8080/realms/demo',
audience: 'account', // Keycloak default aud. For a dedicated aud, see Chapter 11
})
// role guard based on realm_access.roles
function requireRealmRole(role) {
return (req, res, next) => {
const roles = req.auth?.payload?.realm_access?.roles ?? []
if (!roles.includes(role)) {
return res.status(403).json({ error: 'forbidden' })
}
next()
}
}
app.get('/api/public/ping', (req, res) => {
res.json({ msg: 'anyone' })
})
app.get('/api/me', checkJwt, (req, res) => {
res.json({
sub: req.auth.payload.sub,
username: req.auth.payload.preferred_username,
})
})
app.get('/api/admin/secret', checkJwt, requireRealmRole('admin'), (req, res) => {
res.json({ msg: 'only admins' })
})
app.listen(8082, () => console.log('API on :8082'))
If You Want to Verify Directly with jose, No Adapter
When you want fewer libraries and want to see the mechanics:
import { createRemoteJWKSet, jwtVerify } from 'jose'
const JWKS = createRemoteJWKSet(
new URL('http://localhost:8080/realms/demo/protocol/openid-connect/certs')
)
async function verify(token) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'http://localhost:8080/realms/demo',
// audience: 'demo-backend', // when using a dedicated aud
})
return payload // { sub, realm_access, ... }
}
createRemoteJWKSet fetches the public keys, caches them, and handles key rotation automatically.
Chapter 8 · Frontend Integration — Next.js
The frontend's job: send the user to the Keycloak login, and call the backend with the Access Token it gets back. Two approaches.
Approach A — Auth.js (NextAuth v5), Recommended for Next.js
With the Next.js App Router, Auth.js is the smoothest. It handles token exchange, sessions, and refresh server-side, so the token is not exposed to browser JS.
npm install next-auth@beta
// auth.ts
import NextAuth from 'next-auth'
import Keycloak from 'next-auth/providers/keycloak'
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Keycloak({
clientId: process.env.KEYCLOAK_CLIENT_ID, // demo-frontend
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET, // when Confidential
issuer: process.env.KEYCLOAK_ISSUER, // http://localhost:8080/realms/demo
}),
],
callbacks: {
// on first login, stash the Keycloak tokens in the session token
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token
token.refreshToken = account.refresh_token
token.expiresAt = account.expires_at
}
return token
},
async session({ session, token }) {
session.accessToken = token.accessToken
return session
},
},
})
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlers
The bracketed segment in the route path above is Next.js catch-all route syntax (a folder after
authwhose name is the spread token wrapped in square brackets).
Use it from a server component:
// app/page.tsx
import { auth, signIn, signOut } from '@/auth'
export default async function Home() {
const session = await auth()
if (!session) {
return <form action={async () => { 'use server'; await signIn('keycloak') }}>
<button>Log in with Keycloak</button>
</form>
}
return (
<div>
<p>Welcome, {session.user?.email}</p>
<form action={async () => { 'use server'; await signOut() }}>
<button>Log out</button>
</form>
</div>
)
}
Calling the backend — attach the Access Token on the server:
// app/dashboard/page.tsx
import { auth } from '@/auth'
export default async function Dashboard() {
const session = await auth()
const res = await fetch('http://localhost:8082/api/me', {
headers: { Authorization: `Bearer ${session?.accessToken}` },
})
const me = await res.json()
return <pre>{JSON.stringify(me, null, 2)}</pre>
}
Approach B — The keycloak-js Adapter (Pure SPA)
When it is a pure React SPA rather than Next.js, or you want to handle tokens directly on the client side. The Client must be Public (Client authentication: Off).
import Keycloak from 'keycloak-js'
export const keycloak = new Keycloak({
url: 'http://localhost:8080',
realm: 'demo',
clientId: 'demo-frontend',
})
await keycloak.init({
onLoad: 'check-sso',
pkceMethod: 'S256', // PKCE required
})
if (keycloak.authenticated) {
// call the backend
await fetch('http://localhost:8082/api/me', {
headers: { Authorization: `Bearer ${keycloak.token}` },
})
}
// refresh when the token is about to expire
setInterval(() => keycloak.updateToken(30), 20_000)
Which Should You Use
| Auth.js (Approach A) | keycloak-js (Approach B) | |
|---|---|---|
| Token exposure | Stored server-side, safer | Browser memory |
| Fit for Next.js | Very high | Moderate |
| Pure SPA | Not suitable | Suitable |
| Recommendation | This, if Next.js | SPA / legacy React |
Chapter 9 · Recapping the Token Flow — The Full Authorization Code + PKCE Picture
Here is, in one page, what the code in Chapter 8 does under the hood. This is the standard flow for SPAs and web apps.
1. User clicks "Log in"
2. Frontend → redirect to the Keycloak authorization_endpoint
?response_type=code
&client_id=demo-frontend
&redirect_uri=http://localhost:3000/...
&scope=openid profile email
&code_challenge=... (PKCE: SHA-256 of the code_verifier)
&code_challenge_method=S256
3. Keycloak login screen → user authenticates
4. Keycloak → sends back to redirect_uri, ?code=AUTH_CODE
5. Frontend (or the Auth.js server) → exchange at the token_endpoint
grant_type=authorization_code
&code=AUTH_CODE
&code_verifier=... (PKCE: the original verifier — defends against code interception)
6. Keycloak → { access_token, id_token, refresh_token }
7. Call the backend API with access_token
8. When it expires, request the token_endpoint again with refresh_token → new tokens
The Roles of the Three Tokens
| Token | Purpose | Recipient |
|---|---|---|
access_token | Authorize API calls | Resource Server (the backend) |
id_token | Proof of identity — "who logged in" | Client (the frontend) |
refresh_token | Issue a new access_token | Authorization Server (Keycloak) |
A common mistake: sending the id_token to a backend API. The backend should receive the access_token. The id_token is for the frontend to know "I am logged in."
Logout — Cutting the Session Too
Throwing away the token on the frontend is not a real logout. The Keycloak SSO session is still alive, so logging in again sails through without even showing a screen. You have to cut the Keycloak session too with RP-Initiated Logout:
http://localhost:8080/realms/demo/protocol/openid-connect/logout
?id_token_hint=ID_TOKEN
&post_logout_redirect_uri=http://localhost:3000
Auth.js's signOut() handles this (depending on the provider config). For keycloak-js, it is keycloak.logout().
Chapter 10 · Production Checklist
You should not run start-dev in production. The must-do items when going to production.
Switching to Production Mode
# start-dev → start, with explicit hostname and HTTPS
docker run ... quay.io/keycloak/keycloak:26.0 start \
--hostname https://auth.example.com \
--proxy-headers xforwarded # when behind a reverse proxy
startmode — forces HTTPS by default, distributed cache.KC_HOSTNAME— must be explicit. Otherwise theissof issued tokens ends up wrong (a regular bug in Chapter 11).--proxy-headers— when behind Nginx or an ALB, so it trustsX-Forwarded-*.
DB and Infrastructure
- External Postgres/MySQL — H2 is absolutely forbidden. Set up backups and HA.
- Multiple instances + distributed cache (Infinispan) — eliminate the single point of failure.
- TLS —
auth.example.commust be HTTPS.
Security Settings
- Turn on Brute Force Detection — Realm settings → Security defenses. Defends against password brute-forcing.
- Password policy — minimum length, complexity, no reuse.
- Tune token lifespans — Access Token 5–15 minutes, Refresh Token to match the service's characteristics.
- Turn off
Direct access grants— on every production Client. - Narrow the Redirect URIs — no
*. Use exact paths.
Configuration as Code — Realm Export/Import
Settings made by clicking in the console are not reproducible. Export the Realm as JSON, put it in Git, and import it on boot (IaC).
# export
docker exec keycloak /opt/keycloak/bin/kc.sh export \
--dir /tmp/export --realm demo
# import (at container start)
docker run ... quay.io/keycloak/keycloak:26.0 \
start-dev --import-realm # reads JSON from /opt/keycloak/data/import/
For something more refined, manage declarative configuration with a tool like keycloak-config-cli, or use the Terraform Keycloak provider.
Observability
- Turn on
KC_HEALTH_ENABLEDandKC_METRICS_ENABLEDand wire up Prometheus and k8s probes. - Record login failures and admin operations as Events (Realm settings → Events). Audit logs.
Chapter 11 · Troubleshooting — The Usual Sticking Points
The errors you hit 100% of the time in a hands-on. Knowing them in advance saves time.
Invalid redirect_uri
- Cause: trying to return to a URL not registered in the Client's
Valid redirect URIs. - Fix: register the exact URL in the Client settings. Get the port, path, and trailing slash exactly right. Wildcards like
http://localhost:3000/*.
CORS Errors (browser console)
- Cause: the Client's
Web originsis empty or missing the frontend origin. - Fix: add
http://localhost:3000toWeb origins. (Adding a+makes it auto-infer from the redirect URIs.)
Invalid token issuer / The Backend Rejects the Token
- Cause: the token's
issdoes not match the backend'sissuer-uricharacter for character. The usual culprit is alocalhostvs container hostname mismatch. - Example: the backend verifies inside Docker against
http://keycloak:8080/realms/demo, but the token'sissishttp://localhost:8080/realms/demo. - Fix: pin
KC_HOSTNAMEand use the same issuer string everywhere. Decode the token and checkissdirectly (Chapter 5).
aud (audience) Verification Failure
- Cause: the backend requires a specific
audience, but theaudof Keycloak's default token isaccount. - Fix: one of two —
- Match the backend's audience verification to
account, or - Create an Audience Mapper in Keycloak to put
demo-backendintoaud(Client scopes → dedicated scope → Mappers → Audience).
- Match the backend's audience verification to
No Roles in the Token
- Cause: the
rolesClient Scope is not attached to the Client, or the role is not mapped to the User. - Fix: check that
rolesis present asDefaultin the Client → Client scopes tab. Check User → Role mapping. Decode the token and checkrealm_access.rolesdirectly.
Clock Skew — Token is not active / expired
- Cause: the clocks of the Keycloak container and the backend server are out of sync.
- Fix: sync with NTP. Allow a little
clockTolerance(typically a few seconds to a minute) in the verification library.
start Mode but It Won't Come Up — hostname Error
- Cause: production mode requires
KC_HOSTNAME. - Fix: specify the
--hostnameflag or theKC_HOSTNAMEenvironment variable. Also--proxy-headersif behind a reverse proxy.
Chapter 12 · Tips & Anti-Patterns
Practical Tips
- Realms per environment/service — separate
demo-devanddemo-prod. Never usemasterfor an app. - Clients per app — the frontend and the backend are separate Clients. The frontend is Public, the backend is Confidential when needed.
- Manage roles with Groups — attaching roles directly to Users is only fine at small scale. As it grows, use Groups + role mapping.
- Make a habit of always decoding the token — when stuck, eyeball
iss,aud,exp, and the roles withjwt.ioorcut | base64 -d | jq. Do not guess. - Trust the Discovery document — do not hardcode endpoint URLs; read them from
.well-known/openid-configuration. - For local, use docker-compose + Postgres +
--import-realm— put the realm JSON in Git and a teammate gets an identical environment with onedocker compose up. - Keep the Access Token short, refresh with the Refresh Token — 5–15 minutes. Even if leaked, its lifetime is short.
10 Anti-Patterns
- Implementing authentication yourself — Keycloak exists, yet you start by writing password hashing.
- Creating app Clients and Users in the
masterrealm. - Running production on
start-dev(H2 DB, no HTTPS). - Leaving the Redirect URI wide open with
*— open redirect. - Not filling in
Web origins, so CORS keeps blocking you. - Sending the
id_tokento the backend (what you should send is theaccess_token). - Not using PKCE on a frontend Public Client.
- Leaving
Direct access grants(Password Grant) on in production. - Not pinning
KC_HOSTNAME, so verification fails on tokenissmismatch. - Configuring only by clicking in the console — a non-reproducible environment with no export/import.
Epilogue — Keycloak Is Not the "End" but the "Starting Point"
If you followed this article, here is what you now have:
- Keycloak 26 running on Docker (+ Postgres)
- A
demorealm, Public and Confidential Clients, Users/Roles/Groups - Hands-on experience poking the OIDC endpoints — Discovery, token, JWKS
- Spring Boot Resource Server and Node.js Express backend integration
- Next.js (Auth.js) +
keycloak-jsfrontend integration - An understanding of the full Authorization Code + PKCE token flow
- A production checklist and a feel for troubleshooting
The roads forward from here: Identity Provider Brokering (connecting Google/GitHub social login), LDAP/AD User Federation (integrating an in-house directory), custom themes (branding the login screen), Authorization Services (fine-grained permissions — UMA), Token Exchange (converting tokens between services), and Organizations (B2B multi-tenancy, a new feature in Keycloak 26).
The core lesson is one. Authentication and authorization are not areas to build yourself. Stand on a proven solution that implements the standard (OIDC) and focus on your business logic. Keycloak is the most universal self-hosted option on top of that standard.
10-Item Checklist
- Are you ready to run Keycloak in
startmode rather thanstart-dev? - Did you separate the app realm from
master? - Is the frontend Public + PKCE, and the backend an appropriate Client type?
- Did you set the Redirect URIs and Web origins precisely (narrowly)?
- Does the backend verify locally with JWKS (not introspection on every request)?
- Do you verify
iss,aud,exp, and the signature, all of them? - Do roles end up in the token and get converted to backend authorities?
- Do you use the
access_tokenfor the backend and theid_tokenfor identity checks? - Do you cut the Keycloak session too with RP-Initiated Logout?
- Is the Realm configuration reproducible via export/import (or IaC)?
Next Article Preview
Candidates for the next article: Keycloak Identity Provider Brokering — Google/GitHub Social Login and In-House LDAP Integration, Keycloak Custom Themes and Extension (SPI) Development, Keycloak Authorization Services — A UMA-Based Fine-Grained Permissions Lab.
"Good authentication is authentication the user is not conscious of. And good authentication infrastructure is infrastructure the developer did not build themselves."
— Keycloak Integration Hands-On, end.