DEV Community

ko-chan
ko-chan

Posted on • Originally published at ko-chan.github.io

3 Pitfalls of Multi-Portal Authentication with Keycloak [Part 5]

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)  →
Enter fullscreen mode Exit fullscreen mode

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
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

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-token in 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!
Enter fullscreen mode Exit fullscreen mode

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
    },
    // ...
  };
}
Enter fullscreen mode Exit fullscreen mode

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  ✓
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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\"]" } }
Enter fullscreen mode Exit fullscreen mode

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"] } }
Enter fullscreen mode Exit fullscreen mode

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
    },
}
Enter fullscreen mode Exit fullscreen mode

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: roles is not a standard OIDC scope - it's a Keycloak client scope with role mappers. Roles can appear in tokens even without explicitly requesting a roles scope, if the default client scopes include role mappers. However, if you explicitly request roles as a scope, verify the roles client 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
  };
}
Enter fullscreen mode Exit fullscreen mode

Prerequisite: For token.accessToken to exist, you must first persist it in the jwt callback during sign-in (e.g., token.accessToken = account.access_token). The same applies to refreshToken.

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Strict CSP: Limits which scripts can run (not foolproof, but reduces attack surface)
  2. Short expiry: Tokens expire in 5 minutes (limits damage window if stolen)
  3. 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,
};
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

Then handle it in your app:

const { data: session } = useSession();

if (session?.error === 'TokenRefreshFailed') {
  // Force re-login instead of showing cryptic errors
  signIn('keycloak');
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)