Keycloak + Angular + Node in 2026: the complete setup, and the five places everyone gets it wrong
The full Keycloak 26 + Angular 22 + Node setup — PKCE, silent SSO, JWKS verification, audience validation, and the 401 refresh queue — plus the five failure modes that cost the most hours.
If you’ve ever wired Keycloak into an Angular app with a Node API behind it, you know the pattern: the happy-path tutorial works for exactly one afternoon. Then the token expires mid-session, or you deploy behind Docker and every token is suddenly “invalid”, or you discover six months in that your API accepts tokens from any client in your realm.
I’ve built and maintained this stack in production since 2023. This article walks the full setup — and the five failure modes that cost me the most hours, so they don’t cost you any.
The architecture
Three pieces: Keycloak (26.x), an Angular SPA (22.x with keycloak-angular 22 / keycloak-js 26), and an Express API that verifies Keycloak-issued JWTs.
The SPA does Authorization Code + PKCE — the only correct flow for a browser app in 2026. The API never talks to Keycloak per-request; it verifies token signatures against the realm’s JWKS endpoint and checks the claims itself. Stateless, fast, key-rotation safe.
On the Angular side, keycloak-angular’s modern API initializes the client before the app renders — no wrapper service needed:
// app.module.ts (works the same in a standalone bootstrap)
providers: [
provideKeycloak({
config: {url: 'http://localhost:8080', realm: 'my-realm', clientId: 'my-spa'},
initOptions: {
onLoad: 'check-sso', // adopt an existing session, don't force login
pkceMethod: 'S256',
checkLoginIframe: false, // see mistake #5
silentCheckSsoRedirectUri: `${window.location.origin}/assets/silent-check-sso.html`
}
})
]
Routes declare the roles they need; a factory-built guard compares them to the token and sends failures to /forbidden:
// routes
{path: 'admin', canActivate: [canActivateAuthRole], data: {roles: ['realm-admin']}, ...}
// guard — createAuthGuard hands you auth state, roles, and the keycloak instance
const isAccessAllowed = async (route, state, {authenticated, grantedRoles, keycloak}) => {
const router = inject(Router); // inject() only before the first await
if (!authenticated) {
await keycloak.login({redirectUri: `${window.location.origin}${state.url}`});
return false;
}
const required = route.data['roles'] as string[];
if (!required?.length) return true;
const userRoles = [...grantedRoles.realmRoles, ...Object.values(grantedRoles.resourceRoles).flat()];
return required.some(r => userRoles.includes(r)) || router.parseUrl('/forbidden');
};
export const canActivateAuthRole = createAuthGuard<CanActivateFn>(isAccessAllowed);
And the API side — signature via JWKS, plus the two claim checks most examples skip:
const client = jwksClient({jwksUri: `${authServerUrl}/realms/${realm}/protocol/openid-connect/certs`,
cache: true, rateLimit: true});
const getKey = (header, cb) =>
client.getSigningKey(header.kid, (err, key) => cb(err, err ? undefined : key.getPublicKey()));
jwt.verify(token, getKey, {
algorithms: ['RS256'],
issuer: expectedIssuer, // mistake #4 is getting this value wrong behind Docker
audience: myClientId // requires the realm mapper from mistake #2
}, (err, decoded) => { /* ... req.user = mapped claims ... */ });
The five places everyone gets it wrong
1. Verifying tokens against a copy-pasted public key
Every second tutorial has you paste KC_PUBLIC_KEY from the realm settings into your API’s env. It works — until the realm’s keys rotate (which Keycloak does, and should do). Fetch keys from the JWKS endpoint instead (jwks-rsa with caching); rotation becomes a non-event and there’s nothing to copy on day one.
2. Skipping audience validation (or enabling it and wondering why everything breaks)
By default your API accepts any valid token from your realm — including ones minted for completely different apps. aud validation closes that, but here’s the trap: Keycloak doesn’t put your API in aud by default. You need an audience mapper on the SPA’s client. No mapper + audience validation on = every request 401s, and nothing tells you why.
3. The refresh stampede
A dashboard fires six API calls; the access token just expired; all six 401. The naive interceptor refreshes six times — racing refresh-token rotation and occasionally logging your user out mid-click. The fix is a refresh queue: first 401 triggers the refresh, the rest park until the new token arrives, then replay:
private handle401Error(request: HttpRequest<any>, next: HttpHandler) {
if (!this.isRefreshing) {
this.isRefreshing = true;
this.refreshTokenSubject.next(null); // gate closes
return from(this.keycloak.updateToken(30)).pipe( // ONE refresh
switchMap(() => {
this.isRefreshing = false;
this.refreshTokenSubject.next(this.keycloak.token); // gate opens
return next.handle(this.addTokenHeader(request));
}),
catchError(err => { this.keycloak.login(); return throwError(() => err); })
);
}
// every concurrent 401 lands here and waits for the gate instead of refreshing
return this.refreshTokenSubject.pipe(
filter(token => token !== null),
take(1),
switchMap(() => next.handle(this.addTokenHeader(request)))
);
}
One BehaviorSubject, no stampede, and a failed refresh routes to login exactly once.
4. The issuer mismatch behind Docker (or any proxy)
Your API validates iss. Inside Docker, the API reaches Keycloak at http://keycloak:8080, but the browser got its tokens from http://localhost:8080 — and iss records what the browser used. Validate against the internal URL and every token fails. The fix: separate “where I fetch JWKS” from “what issuer I expect”. Same story in production with internal service DNS.
5. The session iframe vs. third-party cookies
checkLoginIframe silently does nothing (or logs console errors) under Safari/Chrome cookie blocking. Turn it off and rely on token lifetimes + refresh. And if you use check-sso, ship the silent-check-sso.html page — without it every app load does a full redirect round trip.
Get the whole thing working in one command
Everything above is wired, tested, and documented in RealmKit — production-grade Keycloak starter kits, one per stack:
- RealmKit for Angular — the SPA side:
check-sso+ PKCE bootstrap, silent SSO, the refresh-queue interceptor from mistake #3, role guards as route data.docker compose up→ working login on first run. - RealmKit for Node.js — the API side: JWKS verification, issuer + audience validation (mistakes #1, #2, and #4 handled), the full role/scope middleware family, Swagger, unit + e2e tests.
- Every kit ships the same realm export — clients, roles, demo users, and the audience mapper — auto-imported on first boot. Buy one layer or both: they compose out of the box.
Free tiers: realmkit-angular-free and realmkit-nodejs-free — the correct bootstrap and one guarded route each, MIT-licensed. Full kits: $29 each, or both for $49 (launch pricing) → realmkit.dev