RBAC for APIs with Keycloak — Local JWT vs. Introspection, and Two Bugs That Taught Me More Than the Code Did
This is module 04 in an ongoing series working through Keycloak from the ground up — realms and roles, PKCE SPAs, service accounts, and now: role-based access control for a resource server. If you've been following along
This is module 04 in an ongoing series working through Keycloak from the
ground up — realms and roles, PKCE SPAs, service accounts, and now: role-based
access control for a resource server. If you've been following along, the
project layout will look familiar (docker-compose.yml, Makefile,
scripts/setup.sh doing all the Keycloak provisioning via the Admin REST
API, no click-ops). If you're just joining, the short version: every module
in this series is a concept walkthrough + a runnable demo, make start && make-able in one go.
setup && make demo
This one's called kc-rbac-api: a Node/Express API with three roles —
viewer, editor, admin — enforced at the endpoint level, with two
interchangeable ways of validating the incoming token. And along the way, two
real Keycloak gotchas showed up that are worth their own writeup, because I
guarantee they'll bite you too.
The setup
Three roles, one hierarchy:
viewer (1) < editor (2) < admin (3)
Six endpoints, deliberately not all following the same "N and above" pattern,
because that's the part worth testing carefully:
| 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 |
✅ | ✅ | ✅ |
That DELETE row is the one worth staring at. It would be easy to build a
single requireMinRole('editor') helper and reuse it everywhere "editor or
above" sounds right — but delete is admin-only, not "editor and up." Two
different middleware shapes handle this cleanly:
// exact allow-list — use when roles aren't hierarchical for this endpoint
requireRole('admin')
// hierarchical — viewer < editor < admin
requireMinRole('editor')
DELETE uses requireRole('admin') explicitly rather than assuming the
hierarchy extends there by default. Small distinction, but it's exactly the
kind of thing that becomes a silent privilege escalation bug if you don't
name it on purpose.
Local JWT validation vs. token introspection
This is the actual concept module 04 is about, so it's worth more than a
table. Every protected route sits behind one of two middlewares, chosen at
boot via TOKEN_VALIDATION_MODE:
Local (jwtAuth.js) — verify the RS256 signature against Keycloak's
JWKS (cached via jwks-rsa), check iss/aud/exp, done. No network call
per request after the first JWKS fetch. Fast. 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.
Introspection (introspectAuth.js) — POST the token to Keycloak's
/token/introspect (RFC 7662) on every request. Slower — one extra
round-trip per call — but Keycloak reports active: false immediately on
revocation. 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 is "correct" — it's a latency-vs-freshness tradeoff, and short
access-token lifespans (I set 300s on the demo realm) are the usual way to
bound local validation's blind spot without paying the introspection cost on
every call.
Why aud matters more than it looks
scripts/setup.sh adds an oidc-audience-mapper so this client's tokens
carry aud: kc-rbac-api, and both middlewares reject anything where that's
missing. Without it, a token minted for some other API in the same realm —
one with excess service-account scope, say — would sail through signature
and issuer checks and get treated as valid here too. That's the "confused
deputy" replay case, and it's an easy one to skip if you're only thinking
about "is this signature real."
Testing the matrix, not just the happy path
The interesting part of RBAC isn't "admin can do everything" — it's proving
the denials are actually denials, for the right reason, not just any 4xx.
I wrote a bash test suite (tests/test.sh) that fetches real tokens for
three demo users and asserts both status code and JSON error body for every
cell in that table above, plus the adversarial cases — garbage tokens,
wrong auth scheme, missing header entirely, wrong audience.
── 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
════════════════════════════════════════════════
Ran it against both validation modes. One assertion actually had to be
mode-aware: a garbage token comes back invalid_token under local
validation (a real signature-parse failure) but token_inactive under
introspection (Keycloak doesn't distinguish malformed from
merely-unrecognized — it just says "not active"). Both are a correct 401;
the vocabulary legitimately differs by design between the two modes. Worth
knowing before you write a test that assumes one error string everywhere.
Two bugs, both Keycloak-flavored, neither in "my" code
This is the part that actually taught me something, so I want to write it
down properly instead of glossing over it.
Bug 1 — "Account is not fully set up." First make setup run, first
token request, straight into an opaque invalid_grant. Turns out recent
Keycloak versions (this project pins 26.7.0) reject direct-grant logins
for users with an incomplete profile — no firstName/lastName/email —
even when enabled and emailVerified are both true and there are no
pending required actions. It's an open upstream
issue, not a
misconfiguration. The fix was two lines: give the demo users an email and a
name when creating them, and explicitly zero out requiredActions. I also
made the user-creation step idempotent — re-running setup.sh against an
already-broken account patches it in place, no make clean required.
Bug 2 — invalid_issuer, and a very instructive one. Got a token via
curl http://localhost:8080/... from the host machine, and the API
(running inside the api container, reaching Keycloak internally at
http://keycloak:8080) rejected it: expected: http://keycloak:8080/realms/rbac-demo.
The root issue: Keycloak stamps a token's iss claim based on whatever
hostname the client used to request it, not some fixed config value. My
own code was conflating two things that are only the same by coincidence in
a single-hostname setup: the address the API uses to reach Keycloak over
the network, and the issuer string a token is expected to carry. Those
diverge the moment there's any asymmetry between how users and how backend
services reach the same Keycloak — which is every real deployment with a
reverse proxy in front of it.
The fix, properly: pin KC_HOSTNAME on the Keycloak container so iss is
consistent no matter which path was used to obtain the token, and split
config.js into two values — KEYCLOAK_URL (network address, can be
http://keycloak:8080 internally) and ISSUER_URL (expected iss, matches
whatever hostname clients actually use). Verified it against a mock
Keycloak serving JWKS on one port while issuing tokens stamped with a
different port's issuer, deliberately reproducing the Docker split before
trusting the fix.
Neither of these was really "my bug" in the sense of broken application
logic — they're both places where Keycloak's actual runtime behavior didn't
match the mental model I'd built from the docs. Which, honestly, is most of
what this series has been about so far.
What's in the repo
-
src/middleware/{jwtAuth,introspectAuth}.js— the two validation modes -
src/middleware/rbac.js—requireRole()andrequireMinRole() -
src/routes/{documents,admin,whoami}.js— the endpoints from the table above -
keycloak/setup.sh— realm, roles, client + audience mapper, three demo users, all via Admin REST API -
tests/test.sh— the 25-case bash suite, CI-exit-code-friendly -
http/requests.http+postman/*.json— the same matrix, interactively
Up next
Per the series plan, module 05 is Identity Brokering — two Keycloak
realms in one Compose stack, with a "corp" realm brokering into a "partner"
realm via OIDC, so I can demonstrate first-broker-login and claim mapping
without needing an actual external IdP. That one's going to have its own
share of "the docs said X, the runtime did Y" moments, I'm sure.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.