The Setup
Saru has 4 portals: System, Provider, Reseller, Consumer. Each runs on a different subdomain, but they share one Keycloak realm.
system.saru.local (port 3001) → Keycloak
provider.saru.local (port 3002) → (single realm,
reseller.saru.local (port 3003) → 4 clients)
consumer.saru.local (port 3004) →
Basic Keycloak + Auth.js integration is well-documented in existing tutorials. This article covers the problems those tutorials don’t mention.
Pitfall 1: Cookie Collision Across Subdomains
The Problem
We wanted cross-subdomain session sharing for potential future use, so we set:
| |
Result: Login to System portal, Provider portal shows the same session. But it’s the wrong user context. The System admin’s token is being used on the Provider portal.
Why It Happens
Auth.js uses cookie names like authjs.session-token (or __Secure-authjs.session-token in HTTPS). With domain: '.saru.local', all subdomains share the same cookie. The first portal to set the cookie wins, and subsequent logins on other portals read that same cookie.
Note: Cookie names vary by Auth.js version and config (e.g.,
next-auth.session-tokenin older versions). Check your actual cookie names in browser devtools.
system.saru.local → sets authjs.session-token (domain=.saru.local)
provider.saru.local → reads same cookie → wrong context!
The Fix
Portal-prefixed cookie names:
| |
Note: Auth.js uses additional cookies for OAuth flows (
state,pkceCodeVerifier). If multiple portals perform concurrent logins, consider prefixing these as well to avoid intermittent auth failures.
Now each portal has its own session:
system.saru.local → authjs.system-session-token
provider.saru.local → authjs.provider-session-token ✓
Lesson Learned
If you don’t need cross-subdomain sharing, just omit domain entirely. Cookies become host-only by default, and you avoid this problem.
Pitfall 2: Custom Claims Don’t Appear in Tokens
The Problem
Our backend needs tenant context: account_id, account_type, capabilities. We stored these as Keycloak user attributes, but they weren’t in the tokens.
| |
Why It Happens
Keycloak doesn’t automatically include user attributes in tokens. You need Protocol Mappers. But even then, there are gotchas.
The Fix
Step 1: Create Protocol Mappers
For each attribute, create a mapper in Keycloak. Mappers can be added to a Client Scope (then assigned to your client) or directly to the client’s “Dedicated Scope.”
| Setting | Value |
|---|---|
| Mapper Type | User Attribute |
| User Attribute | account_id |
| Token Claim Name | account_id |
| Add to ID token | ON |
| Add to access token | ON (if your API validates access tokens) |
| Add to userinfo | ON |
Important: If you add mappers to a Client Scope, make sure that scope is assigned to your client (as default or optional). Otherwise, the mapper won’t execute.
Step 2: Handle Multivalued Attributes Correctly
For capabilities (array of strings), we initially stored it as a JSON string:
| |
Keycloak’s “Multivalued” mapper expects separate values, not a JSON string. Important: Set “Multivalued” to ON in the mapper configuration.
| |
The Keycloak Admin API accepts arrays directly in the attributes map:
| |
Step 3: Custom Scopes (Often Missed)
If you request scope: 'openid roles account_info', custom scopes like account_info need to exist in Keycloak. Standard OIDC only provides openid, profile, email.
Note:
rolesis not a standard OIDC scope - it’s a Keycloak client scope with role mappers. Roles can appear in tokens even without explicitly requesting arolesscope, if the default client scopes include role mappers. However, if you explicitly requestrolesas a scope, verify therolesclient scope is assigned to your client.
Create Client Scopes in Keycloak for custom scopes like account_info, then assign them to your clients. Non-existent or unassigned scopes are silently ignored - your token just won’t include the expected claims.
Lesson Learned
Test your token contents early. Decode a token locally (e.g., jwt-cli, browser devtools, or a local script) and verify your claims are present before writing frontend code that depends on them.
Security note: Never paste production tokens into online decoders like jwt.io - they’re third-party services. Use local tools for real tokens.
Pitfall 3: Token Exposure in Sessions
The Problem
We needed the access token on the client side to call APIs directly:
| |
Prerequisite: For
token.accessTokento exist, you must first persist it in thejwtcallback during sign-in (e.g.,token.accessToken = account.access_token). The same applies torefreshToken.
This works, but it’s a security tradeoff we didn’t fully consider initially.
Why It’s Risky
With accessToken in the session, any JavaScript on your page can access it:
| |
If you have an XSS vulnerability, attackers can steal the token.
The Tradeoffs
| Approach | Pros | Cons |
|---|---|---|
| Expose token | Simple, direct API calls from browser | XSS can steal token |
| BFF pattern | Token stays server-side, client calls BFF only | More complexity, all traffic through Next.js |
Note: “Proxy all calls” is essentially the BFF pattern. The key question is whether your client ever holds a bearer token.
What We Chose
We expose the token, accepting the risk with defense-in-depth measures:
- Strict CSP: Limits which scripts can run (not foolproof, but reduces attack surface)
- Short expiry: Tokens expire in 5 minutes (limits damage window if stolen)
- Refresh token rotation: Each refresh issues a new refresh token (requires “Revoke Refresh Token” enabled in Keycloak realm/client settings)
Honest assessment: These mitigations reduce risk but don’t eliminate it. Any XSS vulnerability means full account compromise for the token’s lifetime. If refresh tokens are also exposed in the session, attackers can extend access beyond the 5-minute window. We accept this tradeoff for our B2B context with trusted users and no user-generated content.
| |
Lesson Learned
There’s no universally “correct” answer. Know your threat model. For a B2B SaaS with trusted users, token exposure with mitigations is often acceptable. For a consumer app with user-generated content (XSS risk), consider BFF.
Bonus: Token Refresh Error Handling
One more thing that bit us: handling refresh failures gracefully.
| |
Then handle it in your app:
| |
Summary
| Pitfall | Solution |
|---|---|
| Cookie collision | Portal-prefixed cookie names |
| Missing claims | Protocol Mappers + correct attribute format |
| Token exposure | Accept tradeoff with mitigations, or use BFF |
The basics of Keycloak + Auth.js are well-documented. It’s these edge cases that cost us debugging time. Hopefully this saves you some.
Series Articles
- Part 1: Tackling Unmaintainable Complexity with Automation
- Part 2: Automated WebAuthn Testing in CI
- Part 3: Next.js × Go Monorepo Architecture
- Part 4: PostgreSQL RLS for Multi-Tenant Isolation
- Part 5: Multi-Portal Authentication Pitfalls (This article)