When several side projects need sign-in, one self-hosted Keycloak beats having each app build its own registration, password reset and email verification. This post covers how to split realms, three common ways apps plug into Keycloak, and the traps that only show up in real use: checking aud, deleted users coming back, refresh tokens racing under React StrictMode, a Spring bean that quietly makes the API depend on Keycloak at startup, stale theme CSS, and health checks that report healthy too early.
One server, one realm per app
According to the Keycloak documentation, "realms are isolated from one another and can only manage and authenticate the users that they control." The setup I chose is one realm per app.
The trade-off: split like this, Keycloak is shared sign-in infrastructure, not "sign in once for every app". Sessions belong to a realm, so signing in to one app doesn't sign you in to another; real single sign-on needs a shared realm.
For unrelated side projects, separate realms make sense:
- Each app gets a sign-in page in its own look, and its own password policy and token lifetimes.
- A user doesn't end up with an account in an app they never signed up for.
- When an app needs user-admin rights (see account deletion below), those rights only reach users in its own realm.
Apps connect to Keycloak in three common ways, each with its own pitfalls.
Pattern 1: a SPA with PKCE, the API only checks JWTs
A browser frontend is a public client: it can't keep a secret, so it uses the Authorization Code flow with PKCE, which RFC 9700 (the current OAuth 2.0 security best practice) makes mandatory for public clients. A library such as oidc-client-ts handles redirects and renews the access token before expiry.
The API doesn't call Keycloak per request: it checks the access token's signature against the realm's public keys (JWKS), plus iss, aud and exp. In Node, jose wraps this up, key caching included:
const jwks = createRemoteJWKSet(new URL(config.OIDC_JWKS_URL));
const { payload } = await jwtVerify(token, jwks, {
issuer: config.OIDC_ISSUER, // must match the realm's public URL exactly
audience: config.OIDC_AUDIENCE, // only accept tokens issued for this API
algorithms: ["RS256", "ES256", "PS256"],
requiredClaims: ["sub", "exp"],
});
Three easy-to-miss details:
- The issuer and where keys are fetched from are two separate settings.
issis always the realm's public URL, but the backend can fetch the JWKS from another address (an internal network, say). Two config values allow both without loosening theisscheck. - EventSource can't send an
Authorizationheader. For an SSE channel the token has to travel another way; the HttpOnly cookie approach is in Server-Sent Events in Spring Boot. - Signing out can't revoke an access token. The API only checks its signature, so it stays usable until
exp. TherevokeTokensOnSignoutoption in oidc-client-ts kills the refresh token at sign-out; for the access token, the only lever is a short lifetime.
Audience: a token from the realm isn't necessarily a token for your API
If the API doesn't check aud, it accepts any unexpired token from the realm, including tokens issued to a different client. By default, the Keycloak documentation says, the frontend client "is not automatically added to the access token audience"; it suggests adding the service's audience with a "hardcoded audience" mapper on the frontend client. It also advises against using the same client as both frontend and REST service.
The tidiest setup: a separate client for the API (say my-api), an audience mapper on the frontend client so tokens carry aud: my-api, and an API that accepts exactly that value. In Spring Boot, checking aud is one validator:
new JwtClaimValidator<List<String>>("aud", aud -> aud != null && aud.contains(expectedAudience))
Pattern 2: the backend exchanges the code, then issues its own session
For an older app with its own password sign-in, role-based permissions and sessions, rebuilding around Keycloak tokens is costly and risky. A lighter option: add Keycloak as one way to sign in: the backend is a confidential client (with a client secret), receives the code at the callback, exchanges it for tokens, calls userinfo, maps the result to a local user and issues the app's own session.
This adds SSO without touching permissions, but:
- Check the
stateparameter at the callback to prevent CSRF on the sign-in flow. - RFC 9700 recommends PKCE for confidential clients too, not just public ones.
- There are two sessions, Keycloak's and the app's, and signing out of one doesn't end the other; a full sign-out has to end both.
Pattern 3: the Admin API through a service account, and deleted users coming back
When users can delete their accounts, deleting the app's data isn't enough: the Keycloak user can still sign in. The API can use a service account (the client_credentials flow) with user-management rights, in its own realm only, to delete the Keycloak user as well.
The trap is elsewhere. Many APIs create the local user just in time: the first request with a valid token for an unknown sub creates the row. After deletion, the access token is still valid for a few minutes, and the next request carrying it recreates the user just deleted.
The fix is a tombstone table, written in the same transaction as the deletion:
await db.transaction(async (tx) => {
// The user's tokens are still valid for a few minutes; they mustn't recreate it.
await tx.insert(deletedUsers).values({ id: userId }).onConflictDoNothing();
await tx.delete(users).where(eq(users.id, userId));
});
And the just-in-time step checks it first:
const [deleted] = await db.select().from(deletedUsers).where(eq(deletedUsers.id, claims.sub));
if (deleted) throw new DeletedUserError();
With JWTs, "deleted" and "token no longer valid" happen at different times, and anything that creates data from a token must allow for that gap.
Refresh token rotation and React StrictMode
A hard one to track down: an app that sometimes signs users out in development, for no obvious reason. Three things combine:
- react-oidc-context's
AuthProvidercreates aUserManagerinside auseStateinitializer if you don't pass one in. - In StrictMode, React calls that initializer twice and discards one result, but the discarded
UserManagerhas already started a renewal timer that lives on. - The realm has Revoke Refresh Token on: each renewal revokes the old refresh token and issues a new one.
Two timers use the same refresh token milliseconds apart. The loser gets invalid_grant, which the app reads as "the session ended", and signs the user out. The fix: one UserManager at module level, passed in:
export const userManager = new UserManager(userManagerSettings);
export const oidcConfig: AuthProviderProps = { userManager, /* ... */ };
It only happens in development, where StrictMode double-calls, but it hides real bugs. Anything with side effects (timers, connections) created in a useState initializer is open to the same problem.
Clock skew: a minute is enough, but don't renew too early
Spring Security allows 60 seconds of clock skew by default when checking exp. It's worth keeping: every extra second is a second an expired token stays usable. The browser should renew about that far ahead of expiry, so a slow network can't leave the API rejecting a token the client hasn't replaced yet.
Don't push renewal any earlier: if the lead time exceeds the access token's lifetime, every token is "about to expire" the moment it's issued, and the client loops renewing. For devices whose clock is far off, the frontend can measure the real drift and tell the user the clock is wrong instead of failing silently.
A hand-declared JwtDecoder can make the API depend on Keycloak at startup
To tune clock skew or add the aud check, people often declare their own JwtDecoder. It's easy to assume NimbusJwtDecoder.withIssuerLocation(...) keeps Spring Boot's auto-configured behavior: Keycloak is contacted only on the first token, so the API starts while Keycloak is down.
It doesn't. The Spring Security documentation says NimbusJwtDecoder looks up the jwkSetUri from the issuer "when build() is invoked". In the 6.5 source (the line Spring Boot 3.5 uses), build() calls processor(), which calls the issuer's metadata endpoint while Spring creates the bean, so if Keycloak is restarting, the API doesn't come up either.
The deferral Spring Boot gives you lives in a different class, SupplierJwtDecoder, and the documentation recommends wrapping any JwtDecoder you declare in it:
@Bean
JwtDecoder jwtDecoder() {
return new SupplierJwtDecoder(() -> {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuerUri).build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(Duration.ofSeconds(clockSkewSeconds)),
new JwtIssuerValidator(issuerUri),
new JwtClaimValidator<List<String>>("aud", aud -> aud != null && aud.contains(expectedAudience))));
return decoder;
});
}
SupplierJwtDecoder only builds the real decoder on the first decode and keeps it once that succeeds; a failed build is retried next time. When you replace an auto-configured bean, read what the original does beyond the part you meant to change.
Per-app themes and CSS that won't go away
Each realm can have a sign-in theme matching its app, account pages included. Baking themes into a pre-built Keycloak image (the "optimized" kind the container guide recommends) turns a theme change into a deploy that can be rolled back.
Two layers of caching can keep serving old CSS after a fix:
- Browser and CDN caches. Theme resource paths don't change when you edit a file, and resources are cached for a long time. At image build time, append
?v=<content hash>to each stylesheet intheme.properties, so a CSS change gets a new URL. - Keycloak's own gzip cache. The theme documentation notes that Keycloak keeps compressed theme files in
data/tmp/kc-gzip-cache, and that deleting it helps after redeploying themes with caching on. That cache is keyed by file path, not query string, so ifdata/sits on a volume that outlives deploys, old CSS keeps being served. Puttingdata/tmpon a tmpfs, for example, gives each container an empty cache.
Health checks should test what users need
Keycloak's readiness endpoint tells you the process is ready, but during startup (especially while migrating the schema after an upgrade) realms can still answer 503. A deploy that relies on readiness alone can report success while nobody can sign in yet.
A sturdier check calls what apps actually need: a realm's discovery document (/realms/<realm>/.well-known/openid-configuration), expecting a 200. In general, a health check should go through the same path users do, not just ask whether the process is alive.
Summary
| Pitfall | What to do |
|---|---|
| Assuming one Keycloak means SSO for every app | SSO belongs to a realm; separate realms mean separate sessions |
| The API accepts any token from the realm | A client for the API, an audience mapper, an aud check |
iss versus where the JWKS comes from |
Two separate settings; iss is always the public URL |
| Sign-out | Revoke the refresh token; keep access tokens short-lived |
| Deleted users recreated from old tokens | A tombstone table, written in the deletion's transaction |
| Random sign-outs in development | One module-level UserManager, not one built in useState |
| API won't start while Keycloak is down | Wrap a hand-declared JwtDecoder in SupplierJwtDecoder |
| Stale theme CSS | ?v=hash at build time; keep the gzip cache off long-lived volumes |
| Deploy reports healthy before sign-in works | Health check calls the realm's discovery document |
If you've run Keycloak for several apps and weighed one shared realm against a realm per app, I'd love to compare notes. My email is on the home page.