Multi-product auth: sharing identity across multiple applications without breaking isolation

When a company grows to operate multiple products — a core SaaS application plus analytics tools, developer APIs, and an admin console — the auth architecture becomes a coordination problem. Users expect a single identity that works across all products. But products have different permission models, different session requirements, and different security boundaries that must not bleed into each other. Building this correctly requires a shared identity layer with strict product-level isolation on top.

The shared IdP model

The foundation is a single identity provider — whether that is a hosted solution like Bastionary, an internally operated instance of an open source IdP, or a custom implementation — that owns user identities, credentials, and the authentication event. All products delegate authentication to this central IdP. They do not maintain their own credential stores.

This gives you a single place to manage MFA policy, SSO configuration, password policy, user provisioning, and audit logging. When a user is deprovisioned, you disable them once in the IdP and all products lose access simultaneously.

Product-scoped tokens with audience claims

Even though all products share an identity layer, tokens issued for one product must not be usable at another. This is enforced through the aud (audience) claim in JWT access tokens. Each product registers as a distinct resource server with its own audience identifier. When a token is issued, it is scoped to a specific audience — or a specific set of audiences if the user needs cross-product access.

// Access token payload for Product A
{
  "iss": "https://auth.example.com",
  "sub": "user_2a3b4c5d",
  "aud": "https://product-a.example.com",  // only valid at Product A
  "iat": 1659312000,
  "exp": 1659312900,
  "scope": "read:data write:data",
  "org_id": "org_7e8f9g",
  "product_roles": {
    "product-a": ["editor"]
    // Product B roles are NOT included in this token
  }
}

// Product B validates audience strictly
app.use('/api', async (req, res, next) => {
  const token = extractBearerToken(req);
  const payload = await verifyJwt(token, {
    audience: 'https://product-b.example.com',  // rejects Product A tokens
    issuer: 'https://auth.example.com'
  });
  req.user = payload;
  next();
});
Never accept tokens with aud: "*" or validate tokens without checking the audience claim. A token issued for your low-security internal tooling product being replayed at your production financial API is a real attack pattern — audience validation prevents it.

Product-scoped sessions and SSO across products

Sessions are more complex. Users expect SSO — log into Product A, navigate to Product B, and already be logged in. But sessions should also be isolated enough that a session compromise in one product does not automatically grant access to others.

The standard implementation is a central SSO session at the IdP level (typically a long-lived HTTP-only cookie at the root domain or a separate auth domain) and shorter-lived product sessions at each application. When the user visits Product B after authenticating at Product A, the application silently exchanges the IdP session for a new product-level session using OIDC prompt=none (silent authentication).

// Product B: check for existing IdP session without user interaction
async function attemptSilentAuth(req: Request, res: Response): Promise<boolean> {
  // Redirect to IdP with prompt=none
  // If the user has an active IdP session, they get a code back immediately
  // If not, we get login_required error and must show the login UI

  const state = generateState();
  const nonce = generateNonce();

  await cacheOAuthState(state, { nonce, returnTo: req.url });

  const authUrl = new URL('https://auth.example.com/authorize');
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('client_id', PRODUCT_B_CLIENT_ID);
  authUrl.searchParams.set('redirect_uri', PRODUCT_B_CALLBACK_URL);
  authUrl.searchParams.set('scope', 'openid profile');
  authUrl.searchParams.set('prompt', 'none');  // silent auth
  authUrl.searchParams.set('state', state);
  authUrl.searchParams.set('nonce', nonce);

  res.redirect(authUrl.toString());
  return true;
}

// Callback handler — silent auth result
app.get('/auth/callback', async (req, res) => {
  const { code, state, error } = req.query;

  if (error === 'login_required') {
    // No IdP session — redirect to login UI
    return res.redirect('/login');
  }

  if (error === 'interaction_required') {
    // IdP session exists but MFA or consent is required
    return res.redirect('/login?prompt=login');
  }

  // Exchange code for tokens
  const tokens = await exchangeCode(code as string, PRODUCT_B_CLIENT_ID);
  await createProductSession(req, res, tokens);

  const cached = await getCachedState(state as string);
  res.redirect(cached.returnTo || '/dashboard');
});

Permission isolation between products

The biggest pitfall in multi-product auth is permission leakage — a user who is an admin in Product A inadvertently getting admin access in Product B because the permission check looks at a global "admin" flag rather than a product-scoped role. Every permission check must be explicitly scoped to the product where the action is taking place.

// Product-scoped permission model
interface UserProductMembership {
  user_id: string;
  product_id: string;  // "product-a" | "product-b" | "admin-console"
  role: string;        // roles are product-specific, not global
  granted_at: Date;
  granted_by: string;
}

// Authorization check always scoped to a product
async function canUserPerformAction(
  userId: string,
  productId: string,
  action: string
): Promise<boolean> {
  const membership = await db.productMemberships.findOne({
    user_id: userId,
    product_id: productId  // scoped lookup — never a global role check
  });

  if (!membership) return false;

  const rolePermissions = PRODUCT_ROLE_PERMISSIONS[productId]?.[membership.role];
  return rolePermissions?.includes(action) ?? false;
}

// Product A admin != Product B admin
const PRODUCT_ROLE_PERMISSIONS = {
  'product-a': {
    viewer: ['read:reports'],
    editor: ['read:reports', 'write:reports'],
    admin: ['read:reports', 'write:reports', 'manage:users']
  },
  'product-b': {
    viewer: ['read:pipelines'],
    operator: ['read:pipelines', 'run:pipelines'],
    admin: ['read:pipelines', 'run:pipelines', 'configure:pipelines', 'manage:users']
  }
};

Cross-product navigation and token hand-off

Sometimes users need to navigate directly from one product to another, potentially carrying context (like "open this specific report in the analytics product"). Deep linking across product boundaries should use OIDC silent auth rather than passing tokens directly in URLs. A token in a URL query parameter ends up in server logs, browser history, and HTTP referrer headers — this is how many token theft incidents happen.

If context must be passed between products (e.g., a record ID to open), put it in session state tied to the OIDC state parameter, not in the URL alongside the token. The receiving product retrieves the context after completing its own authentication flow.

← Back to blog Try Bastionary free →