DEV Community

iapilgrim
iapilgrim

Posted on

Tutorial: Role-Based Access Control for a Node/Express API, Backed by Keycloak

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
Enter fullscreen mode Exit fullscreen mode

Give Keycloak about 30 seconds to finish booting, then provision everything:

make setup
Enter fullscreen mode Exit fullscreen mode

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-api with 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"}
Enter fullscreen mode Exit fullscreen mode

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) => { /* ... */ });
Enter fullscreen mode Exit fullscreen mode

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", ... });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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"
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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", ... });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
════════════════════════════════════════════════
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • Username: admin / Password: admin
  • Realm: rbac-demo
  • Client kc-rbac-apiRoles tab has viewer/editor/adminClient scopesDedicated scopesMappers 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 so setup.sh, the .http file, and Postman can fetch tokens with one curl call per user. Browser-facing apps should use Authorization Code + PKCE instead — keep directAccessGrantsEnabled: false for those clients.
  • The in-memory documents array resets on restart. Swap it for a real datastore before this goes anywhere near real traffic.
  • requireMinRole assumes a strictly linear hierarchy. It works great for viewer < editor < admin, but don't force a non-nesting role into it — use requireRole() 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
Enter fullscreen mode Exit fullscreen mode

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)