Split View: Keycloak 연동 실습 — Docker로 띄우고 Realm·Client 설정, Spring Boot·Next.js 연동까지 (2025 핸즈온 가이드)
Keycloak 연동 실습 — Docker로 띄우고 Realm·Client 설정, Spring Boot·Next.js 연동까지 (2025 핸즈온 가이드)
프롤로그 — 인증을 직접 만들지 마라
신규 프로젝트에서 가장 흔한 실수: 로그인을 직접 구현하는 것. 비밀번호 해싱, 세션, 토큰 발급, 비밀번호 재설정, 소셜 로그인, 2FA, brute-force 방어… 이걸 다 직접 만들면 보안 구멍을 직접 파는 것과 같다.
Keycloak은 Red Hat이 후원하는 오픈소스 IAM(Identity and Access Management)이다. OIDC·OAuth2·SAML을 표준으로 구현했고, 관리 콘솔·소셜 로그인·2FA·LDAP 연동·brute-force 방어가 다 들어 있다. 자체 호스팅이고, 라이선스는 Apache 2.0이다.
2025년 참고: Keycloak은 17 버전부터 Quarkus 기반으로 재작성됐다. 옛날 WildFly 배포판은 사라졌다. 이 글은 Keycloak 26.x 기준이다.
이 글은 이론서가 아니라 핸즈온이다. OAuth2·OIDC 프로토콜 자체의 깊은 설명은 별도 글(OAuth2/OIDC 심층 가이드)에 있고, 여기서는 실제로 띄우고 연동하는 것에 집중한다. 터미널을 열고 따라 하면 된다.
흐름은 이렇다: Docker로 띄우기 → Realm·Client·User 설정 → OIDC 엔드포인트 직접 찔러보기 → Spring Boot·Node.js 백엔드 연동 → Next.js 프런트 연동 → 토큰 흐름 정리 → 프로덕션 체크리스트 → 트러블슈팅.
0장 · 사전 준비물과 큰 그림
준비물
- Docker (Keycloak 실행용)
- JDK 21+ (Spring Boot 실습 시) 또는 Node.js 20+ (Node·Next.js 실습 시)
curl,jq(엔드포인트 찔러보기용)
큰 그림
┌─────────┐ 1. 로그인 리다이렉트 ┌──────────────┐
│ Browser │ ───────────────────────▶ │ Keycloak │
│ │ ◀─────────────────────── │ (IdP, 8080) │
└────┬────┘ 2. 인가 코드 + 토큰 └──────┬───────┘
│ │
│ 3. Access Token 으로 API 호출 │ JWKS 공개키
▼ ▼
┌─────────┐ ┌──────────────┐
│ Backend │ ─── 4. 토큰 서명 검증 ──▶ │ (JWKS 캐싱) │
│ (API) │ └──────────────┘
└─────────┘
핵심: 백엔드는 Keycloak에 매 요청을 묻지 않는다. Keycloak의 공개키(JWKS)를 한 번 받아 캐싱하고, 그 키로 JWT 서명을 로컬에서 검증한다. Keycloak이 죽어도 이미 발급된 토큰은 검증된다.
Keycloak 핵심 개념 7가지
| 개념 | 한 줄 설명 |
|---|---|
| Realm | 격리 경계. 사용자·클라이언트·역할의 독립된 네임스페이스. 보통 서비스/환경당 하나 |
| Client | Keycloak에 등록된 애플리케이션. 프런트엔드 SPA, 백엔드 API 각각이 Client |
| User | 최종 사용자 계정. Realm에 소속 |
| Role | 권한 묶음. Realm Role(전역)과 Client Role(앱별)이 있음 |
| Group | User의 묶음. Group에 Role을 매핑하면 소속 User가 상속 |
| Client Scope | 토큰에 어떤 claim·role을 넣을지 정하는 재사용 단위 |
| Identity Provider | 외부 IdP(Google, GitHub, 또 다른 Keycloak)를 연결하는 브로커링 |
1장 · Keycloak 띄우기 (Docker)
30초 퀵스타트 (dev 모드)
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
버전 주의: Keycloak 26부터 초기 관리자 계정 환경변수가
KC_BOOTSTRAP_ADMIN_USERNAME/KC_BOOTSTRAP_ADMIN_PASSWORD로 바뀌었다. 그 이전 버전은KEYCLOAK_ADMIN/KEYCLOAK_ADMIN_PASSWORD를 쓴다.
브라우저로 http://localhost:8080 접속 → "Administration Console" → admin / admin 로그인. 끝.
start-dev는 인메모리 H2 DB를 쓰고 HTTPS를 강제하지 않는다. 실습·로컬 전용이다. 프로덕션은 10장에서 다룬다.
docker-compose — Postgres 붙인 현실적인 구성
실습이라도 데이터가 날아가면 짜증난다. 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 # "Keycloak ... started" 가 뜨면 준비 완료
dev 모드 vs 프로덕션 모드
start-dev | start | |
|---|---|---|
| HTTPS | 선택 (강제 안 함) | 기본 필수 |
| 캐시 | 로컬 | 분산(Infinispan) |
| hostname | 자동 추론 | 명시 필수 (KC_HOSTNAME) |
| 용도 | 로컬·실습 | 운영 |
Health·Metrics 엔드포인트
KC_HEALTH_ENABLED=true로 켜면 — Keycloak은 관리용 포트(기본 9000)로 노출한다:
http://localhost:9000/health/ready— 준비 상태http://localhost:9000/health/live— 생존 상태http://localhost:9000/metrics— Prometheus 메트릭
2장 · Realm 만들기
Realm은 격리 경계다. master realm은 Keycloak 자체 관리용이므로 건드리지 말고, 우리 서비스용 realm을 새로 만든다.
콘솔에서 만들기
- 좌상단 realm 드롭다운 → Create realm
- Realm name:
demo - Create
이제 모든 작업은 demo realm 안에서 한다.
알아둘 Realm 설정 (Realm settings)
- Tokens 탭
Access Token Lifespan— 기본 5분. 짧을수록 안전, 길수록 편함. 5~15분이 일반적.SSO Session Idle / Max— 리프레시 토큰 수명을 좌우.
- Login 탭
User registration— 셀프 회원가입 허용 여부Forgot password,Remember me,Email as username
- Sessions 탭 — 세션 수명 세부 조정
- Security defenses 탭 —
Brute Force Detection켜기 (10장)
지금은 기본값으로 두고 진행한다.
3장 · Client 설정 — 두 가지 유형
Client는 "Keycloak에 등록된 앱"이다. 우리 실습은 두 개를 만든다:
demo-frontend— Public Client (Next.js SPA). 시크릿 없음, PKCE 사용.demo-backend— Confidential Client (API 서버). 시크릿 있음.
Public Client — 프런트엔드용
좌측 Clients → Create client:
- General settings
- Client type:
OpenID Connect - Client ID:
demo-frontend
- Client type:
- Capability config
Client authentication: Off (← 이게 Public으로 만드는 핵심)Standard flow: On (Authorization Code Flow)Direct access grants: Off (프로덕션에서는 끈다)
- Login settings
Valid redirect URIs:http://localhost:3000/*Valid post logout redirect URIs:http://localhost:3000/*Web origins:http://localhost:3000(← CORS 허용 오리진)
Public Client는 시크릿을 안전하게 보관할 수 없으므로(브라우저 코드는 다 보임) PKCE가 필수다. Keycloak은 Standard flow에서 PKCE를 자동 지원한다.
Confidential Client — 백엔드용
다시 Create client:
- Client ID:
demo-backend - Capability config
Client authentication: On (← Confidential)Standard flow: OnService accounts roles: On (서버 대 서버 통신용 — Client Credentials Grant)
- 생성 후 Credentials 탭에서
Client Secret을 복사해 둔다.
백엔드가 순수 "Resource Server"(토큰 검증만)라면 사실 시크릿이 필요 없다. JWKS로 검증만 하면 되기 때문이다. 시크릿은 백엔드가 직접 토큰을 발급받아야 할 때(Service Account, Token Exchange) 쓴다.
중요 Client 설정 요약
| 설정 | 의미 | 자주 하는 실수 |
|---|---|---|
| Valid redirect URIs | 로그인 후 돌아올 수 있는 URL 화이트리스트 | 너무 넓게 * → open redirect 위험 |
| Web origins | CORS 허용 오리진 | 비워두면 브라우저 호출이 CORS로 막힘 |
| Standard flow | Authorization Code Flow 활성화 | SPA·웹앱은 필수 |
| Direct access grants | username/password 직접 교환 | 프로덕션에서는 꺼라 (테스트 전용) |
| Service accounts | Client Credentials Grant | 서버 대 서버에만 |
4장 · User·Role·Group 만들기
User 만들기
Users → Create new user:
- Username:
alice - Email:
alice@demo.test,Email verified: On - Create → Credentials 탭 → Set password →
alice123,Temporary: Off
(Temporary: On이면 첫 로그인 시 비밀번호 변경을 강제한다. 실습에서는 Off.)
Realm Role 만들기
Realm roles → Create role:
admin하나,user하나 만든다.
Role를 User에 매핑
Users → alice → Role mapping → Assign role → admin, user 선택.
Group으로 묶기 (선택이지만 권장)
사용자가 많아지면 User마다 Role을 거는 건 지옥이다. Group을 쓴다.
- Groups → Create group →
administrators - 그 group의 Role mapping →
admin할당 - Users →
alice→ Groups → Join group →administrators
이제 administrators group에 들어간 사람은 자동으로 admin role을 갖는다.
Role가 토큰에 들어가는 원리
Realm Role은 기본적으로 Access Token의 realm_access.roles 배열에 들어간다. Client Role은 resource_access 객체 아래 클라이언트별 roles 배열에 들어간다. 이건 roles Client Scope의 기본 Mapper가 처리한다.
만약 토큰에 role이 안 보인다면 — Client의 Client scopes 탭에서 roles scope가 Default로 붙어 있는지 확인하라 (11장 트러블슈팅 참고).
5장 · OIDC 엔드포인트 이해 — curl로 직접 찔러보기
연동 코드를 짜기 전에, Keycloak이 뭘 주는지 직접 보자. 이게 핸즈온의 핵심이다.
Discovery 문서 — 모든 것의 지도
curl -s http://localhost:8080/realms/demo/.well-known/openid-configuration | jq
여기서 핵심 엔드포인트들이 다 나온다:
| 엔드포인트 | URL (realm=demo) | 역할 |
|---|---|---|
| issuer | http://localhost:8080/realms/demo | 토큰 발급자 식별자 |
| authorization_endpoint | .../protocol/openid-connect/auth | 로그인 화면으로 보내는 곳 |
| token_endpoint | .../protocol/openid-connect/token | 코드를 토큰으로 교환 |
| userinfo_endpoint | .../protocol/openid-connect/userinfo | 사용자 정보 조회 |
| jwks_uri | .../protocol/openid-connect/certs | 서명 검증용 공개키 |
| end_session_endpoint | .../protocol/openid-connect/logout | 로그아웃 |
테스트용 토큰 받기 (Password Grant)
경고: Password Grant(Direct Access Grants)는 테스트 전용이다. 실제 앱은 Authorization Code Flow를 쓴다. 여기서는 토큰 내용을 빨리 보려고 임시로 켠다 —
demo-backendClient의Direct access grants를 잠깐 On으로.
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
응답:
{
"access_token": "eyJhbGciOiJSUzI1Ni...",
"expires_in": 300,
"refresh_token": "eyJhbGciOiJIUzI1Ni...",
"token_type": "Bearer",
"scope": "profile email"
}
JWT 안에 뭐가 들었나
access_token의 가운데 부분(payload)을 디코드해 보자:
TOKEN="eyJhbGci..." # 위에서 받은 access_token
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq
핵심 claim들:
{
"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"
}
백엔드가 검증할 것: iss(우리 issuer인가), exp(안 만료됐나), aud(나를 향한 토큰인가), 그리고 서명(JWKS 공개키로). 권한 판단은 realm_access.roles로 한다.
이제 코드로 옮기자.
6장 · 백엔드 연동 (1) — Spring Boot Resource Server
Spring Boot에서 Keycloak 전용 어댑터는 더 이상 필요 없다. (구버전 keycloak-spring-boot-adapter는 deprecated.) 표준 Spring Security OAuth2 Resource Server를 쓴다.
의존성
// 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 — 이게 거의 전부다
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/demo
issuer-uri 하나만 주면 Spring이 자동으로 .well-known/openid-configuration을 읽고 jwks_uri를 찾아 공개키를 캐싱한다. 토큰 서명·iss·exp 검증이 자동으로 켜진다.
SecurityConfig — Keycloak role을 Spring 권한으로 변환
기본 상태로는 realm_access.roles를 Spring이 모른다. 변환기를 끼운다.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // @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()); // 무상태 API라 CSRF 불필요
return http.build();
}
// realm_access.roles -> ROLE_* 권한으로 매핑
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")은 내부적으로 ROLE_admin 권한을 찾는다. 그래서 변환기에서 ROLE_ 접두사를 붙였다.
보호된 엔드포인트
@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";
}
}
테스트
# 5장에서 받은 토큰으로
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/me
# 토큰 없이 → 401
curl -i http://localhost:8081/api/me
7장 · 백엔드 연동 (2) — Node.js / Express
Node 진영도 똑같다 — JWKS로 로컬 검증. Auth0이 만든 express-oauth2-jwt-bearer가 가장 간단하다.
설치 & 미들웨어
npm install express express-oauth2-jwt-bearer
import express from 'express'
import { auth } from 'express-oauth2-jwt-bearer'
const app = express()
// JWKS 자동 조회·캐싱, iss·exp·서명 검증
const checkJwt = auth({
issuerBaseURL: 'http://localhost:8080/realms/demo',
audience: 'account', // Keycloak 기본 aud. 전용 aud는 11장 참고
})
// 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'))
어댑터 없이 jose로 직접 검증하고 싶다면
라이브러리를 줄이고 원리를 보고 싶을 때:
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', // 전용 aud를 쓸 때
})
return payload // { sub, realm_access, ... }
}
createRemoteJWKSet이 공개키를 가져와 캐싱하고, 키 회전(rotation)도 자동 처리한다.
8장 · 프런트엔드 연동 — Next.js
프런트엔드의 역할: 사용자를 Keycloak 로그인으로 보내고, 받은 Access Token으로 백엔드를 호출한다. 두 가지 방법.
방법 A — Auth.js (NextAuth v5), Next.js에 권장
Next.js App Router라면 Auth.js가 가장 매끄럽다. 토큰 교환·세션·리프레시를 서버 사이드에서 처리해 토큰이 브라우저 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, // Confidential일 때
issuer: process.env.KEYCLOAK_ISSUER, // http://localhost:8080/realms/demo
}),
],
callbacks: {
// 최초 로그인 시 Keycloak 토큰을 세션 토큰에 보관
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
위 라우트 경로의 대괄호 부분은 Next.js의 catch-all 라우트 문법이다 (
auth다음에...nextauth가 대괄호로 감싸진 폴더).
서버 컴포넌트에서 사용:
// 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>Keycloak으로 로그인</button>
</form>
}
return (
<div>
<p>{session.user?.email} 님 환영합니다</p>
<form action={async () => { 'use server'; await signOut() }}>
<button>로그아웃</button>
</form>
</div>
)
}
백엔드 호출 — 서버에서 Access Token을 붙인다:
// 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>
}
방법 B — keycloak-js 어댑터 (순수 SPA)
Next.js가 아니라 순수 React SPA거나, 클라이언트 사이드에서 직접 토큰을 다루고 싶을 때. Client는 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 필수
})
if (keycloak.authenticated) {
// 백엔드 호출
await fetch('http://localhost:8082/api/me', {
headers: { Authorization: `Bearer ${keycloak.token}` },
})
}
// 토큰 만료 임박 시 갱신
setInterval(() => keycloak.updateToken(30), 20_000)
어느 쪽을 쓸까
| Auth.js (방법 A) | keycloak-js (방법 B) | |
|---|---|---|
| 토큰 노출 | 서버 사이드 보관, 더 안전 | 브라우저 메모리 |
| Next.js 적합도 | 매우 높음 | 보통 |
| 순수 SPA | 부적합 | 적합 |
| 권장 | Next.js면 이것 | SPA·레거시 React |
9장 · 토큰 흐름 정리 — Authorization Code + PKCE 전체 그림
8장의 코드가 내부적으로 하는 일을 한 장으로 정리한다. SPA·웹앱의 표준 흐름이다.
1. 사용자가 "로그인" 클릭
2. 프런트 → Keycloak authorization_endpoint 로 리다이렉트
?response_type=code
&client_id=demo-frontend
&redirect_uri=http://localhost:3000/...
&scope=openid profile email
&code_challenge=... (PKCE: code_verifier의 SHA-256)
&code_challenge_method=S256
3. Keycloak 로그인 화면 → 사용자 인증
4. Keycloak → redirect_uri 로 돌려보냄, ?code=AUTH_CODE
5. 프런트(또는 Auth.js 서버) → token_endpoint 로 교환
grant_type=authorization_code
&code=AUTH_CODE
&code_verifier=... (PKCE: 원본 verifier — 코드 가로채기 방어)
6. Keycloak → { access_token, id_token, refresh_token }
7. access_token 으로 백엔드 API 호출
8. 만료되면 refresh_token 으로 token_endpoint 재요청 → 새 토큰
세 가지 토큰의 역할
| 토큰 | 용도 | 받는 쪽 |
|---|---|---|
access_token | API 호출 인가 | Resource Server (백엔드) |
id_token | "누가 로그인했나" 신원 증명 | Client (프런트엔드) |
refresh_token | 새 access_token 발급 | Authorization Server (Keycloak) |
흔한 실수: id_token을 백엔드 API에 보내는 것. 백엔드는 access_token을 받아야 한다. id_token은 프런트가 "로그인됨"을 아는 용도다.
로그아웃 — 세션까지 끊기
프런트에서 토큰만 버리는 건 진짜 로그아웃이 아니다. Keycloak SSO 세션이 살아 있어서 다시 로그인하면 화면도 안 뜨고 통과된다. RP-Initiated Logout으로 Keycloak 세션까지 끊어야 한다:
http://localhost:8080/realms/demo/protocol/openid-connect/logout
?id_token_hint=ID_TOKEN
&post_logout_redirect_uri=http://localhost:3000
Auth.js의 signOut()은 이걸 처리해 준다 (provider 설정에 따라). keycloak-js는 keycloak.logout().
10장 · 프로덕션 체크리스트
start-dev로 운영하면 안 된다. 운영 전환 시 필수 항목.
프로덕션 모드로 전환
# start-dev → start, hostname·HTTPS 명시
docker run ... quay.io/keycloak/keycloak:26.0 start \
--hostname https://auth.example.com \
--proxy-headers xforwarded # 리버스 프록시 뒤에 있을 때
start모드 — HTTPS를 기본 강제, 분산 캐시.KC_HOSTNAME— 명시 필수. 안 그러면 발급되는 토큰의iss가 엉뚱해진다 (11장 단골 버그).--proxy-headers— Nginx·ALB 뒤에 있으면X-Forwarded-*를 신뢰하도록.
DB·인프라
- Postgres/MySQL 외부 DB — H2 절대 금지. 백업·HA 설정.
- 여러 인스턴스 + 분산 캐시(Infinispan) — 단일 장애점 제거.
- TLS —
auth.example.com은 반드시 HTTPS.
보안 설정
- Brute Force Detection 켜기 — Realm settings → Security defenses. 비밀번호 무차별 대입 방어.
- Password policy — 최소 길이, 복잡도, 재사용 금지.
- Token lifespan 조정 — Access Token 5~15분, Refresh Token은 서비스 특성에 맞게.
Direct access grants끄기 — 모든 프로덕션 Client에서.- Redirect URI 좁히기 —
*금지. 정확한 경로로.
설정을 코드로 — Realm Export/Import
콘솔에서 클릭으로 만든 설정은 재현 불가능하다. Realm을 JSON으로 export 해서 Git에 넣고, 부팅 시 import 한다 (IaC).
# export
docker exec keycloak /opt/keycloak/bin/kc.sh export \
--dir /tmp/export --realm demo
# import (컨테이너 시작 시)
docker run ... quay.io/keycloak/keycloak:26.0 \
start-dev --import-realm # /opt/keycloak/data/import/ 의 JSON을 읽음
더 정교하게는 keycloak-config-cli 같은 도구로 선언적 설정을 관리하거나, Terraform Keycloak provider를 쓴다.
관측성
KC_HEALTH_ENABLED,KC_METRICS_ENABLED켜고 Prometheus·k8s probe 연결.- 로그인 실패·관리 작업은 Events로 남긴다 (Realm settings → Events). 감사 로그.
11장 · 트러블슈팅 — 자주 막히는 곳
핸즈온에서 100% 만나는 에러들. 미리 알아두면 시간을 아낀다.
Invalid redirect_uri
- 원인: Client의
Valid redirect URIs에 등록 안 된 URL로 돌아오려 함. - 해결: Client 설정에 정확한 URL 등록. 포트·경로·trailing slash까지 정확히. 와일드카드는
http://localhost:3000/*처럼.
CORS 에러 (브라우저 콘솔)
- 원인: Client의
Web origins가 비어 있거나 프런트 오리진이 없음. - 해결:
Web origins에http://localhost:3000추가. (+를 넣으면 redirect URI에서 자동 추론.)
Invalid token issuer / 백엔드가 토큰 거부
- 원인: 토큰의
iss와 백엔드의issuer-uri가 글자까지 정확히 일치하지 않음. 단골은localhostvs 컨테이너 호스트명 불일치. - 예: 백엔드가 Docker 안에서
http://keycloak:8080/realms/demo로 검증하는데, 토큰의iss는http://localhost:8080/realms/demo. - 해결:
KC_HOSTNAME을 고정하고, 모든 곳에서 같은 issuer 문자열을 쓴다. 토큰을 디코드해iss를 직접 확인(5장).
aud (audience) 검증 실패
- 원인: 백엔드가 특정
audience를 요구하는데 Keycloak 기본 토큰의aud는account. - 해결: 둘 중 하나 —
- 백엔드의 audience 검증을
account로 맞추거나, - Keycloak에서 Audience Mapper를 만들어
demo-backend를aud에 넣는다 (Client scopes → 전용 scope → Mappers → Audience).
- 백엔드의 audience 검증을
토큰에 role이 없다
- 원인: Client에
rolesClient Scope가 안 붙었거나, role을 User에 매핑 안 함. - 해결: Client → Client scopes 탭에
roles가Default로 있는지 확인. User → Role mapping 확인. 토큰 디코드해서realm_access.roles직접 확인.
Clock skew — Token is not active / expired
- 원인: Keycloak 컨테이너와 백엔드 서버의 시계가 어긋남.
- 해결: NTP 동기화. 검증 라이브러리의
clockTolerance(보통 몇 초~1분)를 약간 허용.
start 모드인데 안 뜸 — hostname 에러
- 원인: 프로덕션 모드는
KC_HOSTNAME이 필수. - 해결:
--hostname또는KC_HOSTNAME환경변수 지정. 리버스 프록시 뒤면--proxy-headers도.
12장 · 팁 & 안티패턴
실전 팁
- Realm은 환경/서비스 단위로 —
demo-dev,demo-prod를 분리.master는 절대 앱용으로 쓰지 마라. - Client는 앱 단위로 — 프런트와 백엔드는 별개 Client. 프런트는 Public, 백엔드는 필요 시 Confidential.
- role은 Group으로 관리 — User에 직접 role 거는 건 소규모만. 커지면 Group + role mapping.
- 토큰을 항상 디코드해 보는 습관 — 막히면
jwt.io나cut | base64 -d | jq로iss·aud·exp·roles를 눈으로 확인. 추측하지 마라. - Discovery 문서를 신뢰 — 엔드포인트 URL을 하드코딩하지 말고
.well-known/openid-configuration에서 읽어라. - 로컬은 docker-compose + Postgres +
--import-realm— realm JSON을 Git에 넣으면 팀원이docker compose up한 번으로 동일 환경. - Access Token은 짧게, Refresh Token으로 갱신 — 5~15분. 유출돼도 수명이 짧다.
안티패턴 10가지
- 인증을 직접 구현 — Keycloak이 있는데 비밀번호 해싱부터 짠다.
masterrealm에 앱 Client·User를 만든다.start-dev로 프로덕션 운영 (H2 DB, HTTPS 없음).- Redirect URI를
*로 활짝 열어둠 — open redirect. Web origins안 채워서 CORS로 계속 막힘.- 백엔드에
id_token을 보냄 (보내야 할 건access_token). - 프런트엔드 Public Client에 PKCE 미사용.
Direct access grants(Password Grant)를 프로덕션에서 켜둠.KC_HOSTNAME안 잡아서 토큰iss불일치로 검증 실패.- 콘솔 클릭으로만 설정 — export/import 없이 재현 불가능한 환경.
에필로그 — Keycloak은 "끝"이 아니라 "시작점"
이 글을 따라 했다면 이제 갖춘 것:
- Docker로 띄운 Keycloak 26 (+ Postgres)
demorealm, Public·Confidential Client, User·Role·Group- OIDC 엔드포인트를 직접 찔러본 경험 — Discovery, token, JWKS
- Spring Boot Resource Server, Node.js Express 백엔드 연동
- Next.js(Auth.js) +
keycloak-js프런트 연동 - Authorization Code + PKCE 전체 토큰 흐름의 이해
- 프로덕션 체크리스트와 트러블슈팅 감각
여기서 더 나아갈 길: Identity Provider Brokering(Google·GitHub 소셜 로그인 연결), LDAP/AD User Federation(사내 디렉터리 연동), 커스텀 테마(로그인 화면 브랜딩), Authorization Services(fine-grained 권한 — UMA), Token Exchange(서비스 간 토큰 변환), Organizations(B2B 멀티테넌시, Keycloak 26 신기능).
핵심 교훈은 하나다. 인증·인가는 직접 만들 영역이 아니다. 표준(OIDC)을 구현한 검증된 솔루션 위에 올라타고, 당신은 비즈니스 로직에 집중하라. Keycloak은 그 표준 위의 가장 보편적인 자체 호스팅 선택지다.
10개 항목 체크리스트
- Keycloak을
start-dev가 아닌start모드로 띄울 준비가 됐는가? - 앱용 realm을
master와 분리했는가? - 프런트는 Public + PKCE, 백엔드는 적절한 Client 유형인가?
- Redirect URI와 Web origins를 정확히(좁게) 설정했는가?
- 백엔드가 JWKS로 로컬 검증하는가 (매 요청 introspection 아님)?
iss·aud·exp· 서명을 모두 검증하는가?- role이 토큰에 들어가고 백엔드 권한으로 변환되는가?
- 백엔드에
access_token을, 신원 확인엔id_token을 쓰는가? - RP-Initiated Logout으로 Keycloak 세션까지 끊는가?
- Realm 설정을 export/import(또는 IaC)로 재현 가능한가?
다음 글 예고
다음 글 후보: Keycloak Identity Provider Brokering — Google·GitHub 소셜 로그인과 사내 LDAP 연동, Keycloak 커스텀 테마·확장(SPI) 개발, Keycloak Authorization Services — UMA 기반 fine-grained 권한 실습.
"좋은 인증은 사용자가 의식하지 못하는 인증이다. 그리고 좋은 인증 인프라는 개발자가 직접 만들지 않은 인프라다."
— Keycloak 연동 실습, 끝.
Keycloak Integration Hands-On — Run It with Docker, Configure Realm/Client, Wire Up Spring Boot and Next.js (2025 Hands-On Guide)
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.