This article was originally published on Saru Blog.
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:
cookies: {
sessionToken: {
options: {
domain: '.saru.local', // Share across subdomains
},
},
}
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:
// packages/auth/src/config.ts
export function getAuthConfig({ portal }: { portal: PortalType }) {
return {
cookies: {
sessionToken: {
name: `authjs.${portal}-session-token`, // e.g., authjs.system-session-token
options: {
domain: COOKIE_DOMAIN,
},
},
callbackUrl: {
name: `authjs.${portal}-callback-url`,
},
csrfToken: {
name: `authjs.${portal}-csrf-token`,
},
// Also prefix state/pkceCodeVerifier if using OAuth flows
},
// ...
};
}
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.
// Expected in profile
profile.account_id // undefined
profile.capabilities // undefined
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:
// Wrong! This stores a literal string, not an array
{ "attributes": { "capabilities": "[\"CONSUME\", \"PROVIDE\"]" } }
Keycloak's "Multivalued" mapper expects separate values, not a JSON string. Important: Set "Multivalued" to ON in the mapper configuration.
// Correct: Keycloak Admin API user update payload
{ "attributes": { "capabilities": ["CONSUME", "PROVIDE"] } }
The Keycloak Admin API accepts arrays directly in the attributes map:
// When syncing capabilities from your app via Admin API
userUpdate := map[string]interface{}{
"attributes": map[string][]string{
"capabilities": {"CONSUME", "PROVIDE"}, // Array, not JSON string
},
}
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:
// Session callback - exposes token to client
async session({ session, token }) {
return {
...session,
accessToken: token.accessToken, // Exposed to client JS
};
}
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:
const { data: session } = useSession();
console.log(session.accessToken); // Any script can do this
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.
// Token refresh with rotation
return {
accessToken: refreshedTokens.access_token,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
expiresAt: Math.floor(Date.now() / 1000) + refreshedTokens.expires_in,
};
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.
// Note: client_secret is for confidential clients (server-side).
// For public clients (SPAs), use PKCE without client_secret.
async function refreshAccessToken(token: JWT): Promise<JWT> {
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: token.refreshToken as string,
client_id: clientId,
client_secret: clientSecret, // Confidential clients only
}),
});
// Defensive: some error responses may not be JSON
let refreshedTokens;
try {
refreshedTokens = await response.json();
} catch {
return { ...token, error: 'TokenRefreshFailed' };
}
if (!response.ok) {
// Don't just throw - return an error state
const error = refreshedTokens.error;
const errorDesc = refreshedTokens.error_description || '';
// Check for user-related errors
if (errorDesc.includes('deleted') || errorDesc.includes('disabled')) {
return { ...token, error: 'UserDeleted' };
}
// invalid_grant covers expired/revoked refresh tokens
if (error === 'invalid_grant') {
return { ...token, error: 'TokenRefreshFailed' };
}
return { ...token, error: 'TokenRefreshFailed' };
}
// Defensive: expires_in might be missing in some edge cases
const expiresIn = refreshedTokens.expires_in ?? 300; // Default to 5 min
return {
...token,
accessToken: refreshedTokens.access_token,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
expiresAt: Math.floor(Date.now() / 1000) + expiresIn,
};
}
Then handle it in your app:
const { data: session } = useSession();
if (session?.error === 'TokenRefreshFailed') {
// Force re-login instead of showing cryptic errors
signIn('keycloak');
}
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)
Top comments (0)