Skip to content

✍️ 필사 모드: Keycloak Integration Hands-On — Run It with Docker, Configure Realm/Client, Wire Up Spring Boot and Next.js (2025 Hands-On Guide)

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

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

ConceptOne-line description
RealmIsolation boundary. An independent namespace for users, clients, and roles. Usually one per service/environment
ClientAn application registered with Keycloak. A frontend SPA and a backend API are each a Client
UserAn end-user account. Belongs to a Realm
RoleA permission bundle. There are Realm Roles (global) and Client Roles (per-app)
GroupA bundle of Users. Map a Role to a Group and member Users inherit it
Client ScopeA reusable unit that decides which claims/roles go into a token
Identity ProviderBrokering 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 use KEYCLOAK_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-devstart
HTTPSOptional (not forced)Required by default
CacheLocalDistributed (Infinispan)
hostnameAuto-inferredMust be explicit (KC_HOSTNAME)
PurposeLocal / labsProduction

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 state
  • http://localhost:9000/health/live — liveness state
  • http://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

  1. Realm dropdown at the top left → Create realm
  2. Realm name: demo
  3. 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 allowed
    • Forgot 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-frontendPublic Client (Next.js SPA). No secret, uses PKCE.
  • demo-backendConfidential Client (API server). Has a secret.

Public Client — For the Frontend

Left side ClientsCreate client:

  1. General settings
    • Client type: OpenID Connect
    • Client ID: demo-frontend
  2. 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)
  3. 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:

  1. Client ID: demo-backend
  2. Capability config
    • Client authentication: On (← Confidential)
    • Standard flow: On
    • Service accounts roles: On (for server-to-server communication — Client Credentials Grant)
  3. After creation, copy the Client Secret from 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

SettingMeaningCommon mistake
Valid redirect URIsWhitelist of URLs allowed to return to after loginToo broad with * → open redirect risk
Web originsAllowed CORS originsLeaving it empty blocks browser calls with CORS
Standard flowEnables Authorization Code FlowRequired for SPAs and web apps
Direct access grantsDirect username/password exchangeTurn it off in production (test only)
Service accountsClient Credentials GrantServer-to-server only

Chapter 4 · Creating Users, Roles, and Groups

Creating a User

UsersCreate new user:

  • Username: alice
  • Email: alice@demo.test, Email verified: On
  • CreateCredentials tabSet passwordalice123, Temporary: Off

(Temporary: On forces a password change on first login. For the lab, set it to Off.)

Creating Realm Roles

Realm rolesCreate role:

  • Create one admin and one user.

Mapping a Role to a User

UsersaliceRole mappingAssign role → select admin and user.

When you have many users, attaching a Role per User is hell. Use Groups.

  1. GroupsCreate groupadministrators
  2. That group's Role mapping → assign admin
  3. UsersaliceGroupsJoin groupadministrators

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:

EndpointURL (realm=demo)Role
issuerhttp://localhost:8080/realms/demoToken issuer identifier
authorization_endpoint.../protocol/openid-connect/authWhere you send the user for the login screen
token_endpoint.../protocol/openid-connect/tokenExchange a code for tokens
userinfo_endpoint.../protocol/openid-connect/userinfoLook up user info
jwks_uri.../protocol/openid-connect/certsPublic keys for signature verification
end_session_endpoint.../protocol/openid-connect/logoutLogout

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-backend Client's Direct access grants to 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.

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 auth whose 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 exposureStored server-side, saferBrowser memory
Fit for Next.jsVery highModerate
Pure SPANot suitableSuitable
RecommendationThis, if Next.jsSPA / 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

TokenPurposeRecipient
access_tokenAuthorize API callsResource Server (the backend)
id_tokenProof of identity — "who logged in"Client (the frontend)
refresh_tokenIssue a new access_tokenAuthorization 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
  • start mode — forces HTTPS by default, distributed cache.
  • KC_HOSTNAME — must be explicit. Otherwise the iss of issued tokens ends up wrong (a regular bug in Chapter 11).
  • --proxy-headers — when behind Nginx or an ALB, so it trusts X-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.
  • TLSauth.example.com must 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_ENABLED and KC_METRICS_ENABLED and 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 origins is empty or missing the frontend origin.
  • Fix: add http://localhost:3000 to Web origins. (Adding a + makes it auto-infer from the redirect URIs.)

Invalid token issuer / The Backend Rejects the Token

  • Cause: the token's iss does not match the backend's issuer-uri character for character. The usual culprit is a localhost vs container hostname mismatch.
  • Example: the backend verifies inside Docker against http://keycloak:8080/realms/demo, but the token's iss is http://localhost:8080/realms/demo.
  • Fix: pin KC_HOSTNAME and use the same issuer string everywhere. Decode the token and check iss directly (Chapter 5).

aud (audience) Verification Failure

  • Cause: the backend requires a specific audience, but the aud of Keycloak's default token is account.
  • Fix: one of two —
    1. Match the backend's audience verification to account, or
    2. Create an Audience Mapper in Keycloak to put demo-backend into aud (Client scopes → dedicated scope → Mappers → Audience).

No Roles in the Token

  • Cause: the roles Client Scope is not attached to the Client, or the role is not mapped to the User.
  • Fix: check that roles is present as Default in the Client → Client scopes tab. Check User → Role mapping. Decode the token and check realm_access.roles directly.

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 --hostname flag or the KC_HOSTNAME environment variable. Also --proxy-headers if behind a reverse proxy.

Chapter 12 · Tips & Anti-Patterns

Practical Tips

  • Realms per environment/service — separate demo-dev and demo-prod. Never use master for 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 with jwt.io or cut | 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 one docker 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

  1. Implementing authentication yourself — Keycloak exists, yet you start by writing password hashing.
  2. Creating app Clients and Users in the master realm.
  3. Running production on start-dev (H2 DB, no HTTPS).
  4. Leaving the Redirect URI wide open with * — open redirect.
  5. Not filling in Web origins, so CORS keeps blocking you.
  6. Sending the id_token to the backend (what you should send is the access_token).
  7. Not using PKCE on a frontend Public Client.
  8. Leaving Direct access grants (Password Grant) on in production.
  9. Not pinning KC_HOSTNAME, so verification fails on token iss mismatch.
  10. 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 demo realm, 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-js frontend 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

  1. Are you ready to run Keycloak in start mode rather than start-dev?
  2. Did you separate the app realm from master?
  3. Is the frontend Public + PKCE, and the backend an appropriate Client type?
  4. Did you set the Redirect URIs and Web origins precisely (narrowly)?
  5. Does the backend verify locally with JWKS (not introspection on every request)?
  6. Do you verify iss, aud, exp, and the signature, all of them?
  7. Do roles end up in the token and get converted to backend authorities?
  8. Do you use the access_token for the backend and the id_token for identity checks?
  9. Do you cut the Keycloak session too with RP-Initiated Logout?
  10. 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.

현재 단락 (1/488)

The most common mistake on a new project: **implementing login yourself.** Password hashing, session...

작성 글자: 0원문 글자: 26,397작성 단락: 0/488