This is a hands-on walkthrough for kc-rbac-api — module 04 in a series
building up Keycloak features one small, runnable project at a time. By the
end you'll have a Node/Express API with three roles enforced at the
endpoint level, two interchangeable ways of validating incoming tokens, and
a full test suite proving the denials work as intended, not just the happy
path.
Everything below assumes Docker + Docker Compose, Node.js, curl, and
python3 (used by the setup script for JSON parsing — nothing else).
What you're building
An API with three roles — viewer, editor, admin — where each endpoint
enforces a specific minimum role, not just "logged in or not":
| Endpoint | viewer | editor | admin |
|---|---|---|---|
GET /api/documents |
✅ | ✅ | ✅ |
POST /api/documents |
❌ | ✅ | ✅ |
PUT /api/documents/:id |
❌ | ✅ | ✅ |
DELETE /api/documents/:id |
❌ | ❌ | ✅ |
GET /api/admin/stats |
❌ | ❌ | ✅ |
GET /api/whoami |
✅ | ✅ | ✅ |
Notice DELETE breaks the simple "editor and up" pattern — it's
admin-only. That's deliberate, and it's the detail worth testing carefully
later on.
Step 1 — Start Keycloak and the API
make start # docker compose up -d --build: Keycloak + this API's container
Give Keycloak about 30 seconds to finish booting, then provision everything:
make setup
This runs keycloak/setup.sh, which — entirely through Keycloak's Admin
REST API, no clicking through the console — creates:
- a realm called
rbac-demo - three realm roles:
viewer,editor,admin - a confidential client
kc-rbac-apiwith an audience mapper (more on that below) - three demo users, one per role, and writes the client secret into
.env
| Username | Password | Role |
|---|---|---|
| alice | alice | viewer |
| bob | bob | editor |
| carol | carol | admin |
Confirm the API is up:
curl http://localhost:3000/health
# {"status":"ok","validationMode":"local"}
Step 2 — Understand the two ways to validate a token
Every protected route sits behind one of two middlewares, and which one is
active is a config toggle (TOKEN_VALIDATION_MODE in .env) — not a code
change:
src/middleware/jwtAuth.js (local) verifies the token's RS256
signature locally, against Keycloak's JWKS endpoint (cached via
jwks-rsa), then checks iss, aud, and exp:
jwt.verify(token, getSigningKey, {
algorithms: ["RS256"],
issuer: config.issuer,
audience: config.clientId,
}, (err, payload) => { /* ... */ });
Fast — one JWKS fetch, then it's pure CPU per request after that. The
tradeoff: if Keycloak revokes the token or the user logs out server-side,
this middleware has no way to know. The token stays "valid" here until it
naturally expires.
src/middleware/introspectAuth.js (introspect) instead POSTs the
token to Keycloak's /token/introspect endpoint (RFC 7662) on every
request:
const { data } = await axios.post(config.introspectionUrl, params, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
if (!data.active) {
return res.status(401).json({ error: "token_inactive", ... });
}
Slower — one extra network round trip per call — but revocation is
immediate: Keycloak reports active: false the moment a token is no
longer valid, regardless of its exp. This mode needs its own client
credentials, since introspection is itself an authenticated endpoint —
only clients that own or trust the token's audience get to ask Keycloak
about it.
Neither one is "the right answer" on its own — it's a latency-vs-freshness
tradeoff. Short access-token lifespans (this realm is set to 300 seconds)
are the usual way to bound local validation's blind spot without paying
the introspection cost on every single call.
Try switching modes and watching /api/whoami reflect it:
TOKEN_VALIDATION_MODE=introspect npm start
TOKEN=$(curl -s -X POST http://localhost:8080/realms/rbac-demo/protocol/openid-connect/token \
-d "grant_type=password&client_id=kc-rbac-api&client_secret=$CLIENT_SECRET&username=carol&password=carol" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
curl http://localhost:3000/api/whoami -H "Authorization: Bearer $TOKEN"
# "authMode": "introspect"
Step 3 — Enforce roles with two middleware shapes
src/middleware/rbac.js gives you two ways to gate a route, and picking
the right one matters:
// Exact allow-list — use when roles AREN'T hierarchical for this endpoint
requireRole("admin")
// Hierarchical — viewer(1) < editor(2) < admin(3)
requireMinRole("editor")
requireMinRole is what powers most of the table above — POST/PUT
use requireMinRole("editor") since editor-and-up should both succeed. But
DELETE uses requireRole("admin") explicitly, not
requireMinRole("admin") treated as "the top of the chain" by accident —
it's named as an exact requirement on purpose. If you ever add a role that
doesn't nest cleanly into the hierarchy (say, an auditor who can read
things editor can't but can't write anything), model it with
requireRole() allow-lists rather than forcing it into the rank table.
Roles themselves come out of the token via src/utils/tokenClaims.js,
which merges Keycloak's realm_access.roles (realm-wide roles) and
resource_access.<clientId>.roles (client-scoped roles) into one flat set
— route middleware doesn't care which bucket a role happened to live in.
Step 4 — Understand why the audience (aud) check exists
keycloak/setup.sh adds an oidc-audience-mapper so tokens for this
client carry aud: kc-rbac-api, and both middlewares reject anything
where that's missing:
const aud = Array.isArray(data.aud) ? data.aud : [data.aud];
if (!aud.includes(config.clientId)) {
return res.status(401).json({ error: "invalid_audience", ... });
}
Without this check, a token minted for a different API in the same
realm — one with broader service-account scope, say — would pass
signature and issuer checks just fine and get treated as valid here too.
That's the "confused deputy" problem: a token issued for one purpose
getting replayed against a resource server it was never meant for. aud
is the check that closes that gap.
Step 5 — Run the full allow/deny matrix
make test
This runs tests/test.sh, a self-contained bash suite: it fetches real
tokens for alice/bob/carol, hits every endpoint as every role, and asserts
both the HTTP status and the JSON error code — so a 403 for the wrong
reason still fails the test.
── 6. DELETE /api/documents/:id (admin only) ──
✓ PASS [DENY] viewer cannot delete (got 403, error=insufficient_role)
✓ PASS [DENY] editor cannot delete (got 403, error=insufficient_role)
✓ PASS [ALLOW] admin can delete (got 204)
════════════════════════════════════════════════
Results: 25 passed, 0 failed
════════════════════════════════════════════════
It's mode-aware: one assertion checks for invalid_token under local
validation but token_inactive under introspection, since Keycloak's
introspection endpoint doesn't distinguish "malformed" from
"merely-unrecognized" — both are a correct 401, just different
vocabulary. Exits non-zero on any failure, so it's safe to drop straight
into CI.
Prefer clicking through requests instead of reading a script? The same
matrix is in http/requests.http (VS Code REST Client — token requests
save into named variables the later requests reuse automatically) and as
a Postman collection + environment in postman/, with pm.test
assertions on every request.
Step 6 — Poke around the Keycloak Admin Console
http://localhost:8080
- Username:
admin/ Password:admin - Realm:
rbac-demo - Client
kc-rbac-api→ Roles tab hasviewer/editor/admin→ Client scopes → Dedicated scopes → Mappers has the audience mapper you just saw enforced in code
Worth clicking into a user (say, carol) and looking at Role mapping
to see the realm role assignment that ends up in realm_access.roles on
her token.
Before you take this to production
A few deliberate simplifications worth knowing about:
-
This demo uses Resource Owner Password Credentials (
grant_type=password) purely sosetup.sh, the.httpfile, and Postman can fetch tokens with one curl call per user. Browser-facing apps should use Authorization Code + PKCE instead — keepdirectAccessGrantsEnabled: falsefor those clients. -
The in-memory
documentsarray resets on restart. Swap it for a real datastore before this goes anywhere near real traffic. -
requireMinRoleassumes a strictly linear hierarchy. It works great forviewer < editor < admin, but don't force a non-nesting role into it — userequireRole()allow-lists instead.
Where the pieces live
| File | Purpose |
|---|---|
keycloak/setup.sh |
Realm, roles, client + audience mapper, 3 demo users — all via Admin REST API |
src/middleware/jwtAuth.js |
Local JWT validation (JWKS) |
src/middleware/introspectAuth.js |
RFC 7662 introspection validation |
src/middleware/rbac.js |
requireRole() / requireMinRole()
|
src/routes/documents.js |
The CRUD resource from the table above |
src/routes/admin.js |
Admin-only endpoint |
src/routes/whoami.js |
Debug endpoint — inspect resolved roles/claims for your own token |
tests/test.sh |
The 25-case bash suite from Step 5 |
http/requests.http / postman/*.json
|
Same matrix, interactive |
Recap
┌─────────────┐
client ── access_token ─► kc-rbac-api │
└──────┬──────┘
│
validationMode = local validationMode = introspect
│ │
verify sig vs JWKS POST /token/introspect
check iss/aud/exp (client_id + client_secret)
read roles from claims check active + aud
│ │
└─────────► requireMinRole() / requireRole() ◄─────────┘
│
200 / 201 / 204 or 403 insufficient_role
Two validation strategies, one audience check standing between them and
"any signed token gets in," and a role hierarchy that's explicit about
where it stops being hierarchical. That's the whole module.
Next up in the series: module 05 — Identity Brokering, wiring up two
Keycloak realms in one Compose stack so a "corp" realm can broker into a
"partner" realm via OIDC, without needing an actual external IdP to test
against.
Top comments (0)