Frontend OAuth 2.0/OIDC: Session, Tokens & Tenant-Aware UX

Master advanced frontend OAuth 2.0 and OIDC implementation, covering secure token handling, robust session management, and tenant-aware user experiences for complex SPAs.

T

TechScale

September 15, 2026 14 min read
Share:
Frontend OAuth 2.0/OIDC: Session, Tokens & Tenant-Aware UX

Mastering Frontend Authentication with OAuth 2.0 and OIDC

Building modern Single Page Applications (SPAs) and Progressive Web Apps (PWAs) requires a robust and secure authentication system. While backend services often handle the heavy lifting of user identity, the frontend plays a critical role in orchestrating the authentication flow, managing tokens, maintaining user sessions, and adapting to complex scenarios like multi-tenancy.

This deep dive will guide intermediate to senior engineers through the intricacies of implementing OAuth 2.0 and OpenID Connect (OIDC) authentication flows directly on the frontend. We'll explore best practices for secure token and session management, and tackle the challenges of delivering a seamless, tenant-aware user experience. Get ready to elevate your application's security and user authentication architecture.

OAuth 2.0 & OIDC: A Frontend Perspective

Before diving into implementation, let's briefly clarify the roles of OAuth 2.0 and OIDC from a frontend standpoint:

  • OAuth 2.0: An authorization framework that allows a user (resource owner) to grant a client application (your frontend) limited access to their resources on a resource server, without exposing their credentials. It defines how tokens are issued and exchanged.

  • OpenID Connect (OIDC): An identity layer built on top of OAuth 2.0. OIDC introduces the ID Token, a JSON Web Token (JWT) that contains verifiable claims about the end-user, such as their name, email, and a unique identifier. This is crucial for authentication (who the user is), whereas OAuth 2.0 is primarily for authorization (what the user can do).

For frontend applications, the Authorization Code Flow with Proof Key for Code Exchange (PKCE) is the gold standard. It mitigates security risks associated with public clients (like SPAs) and ensures that the authorization code cannot be intercepted and used by malicious actors.

Never Use Implicit Flow

The **Implicit Flow** is deprecated for public clients due to inherent security risks. Always use the **Authorization Code Flow with PKCE** for SPAs and mobile applications to prevent authorization code interception attacks and ensure proper client authentication.

The Authorization Code Flow with PKCE for SPAs

This flow ensures that even if an authorization code is intercepted, it cannot be exchanged for tokens without the PKCE code_verifier, which remains private to your frontend application. Here's a simplified sequence of events:

  1. Generate code_verifier: Your frontend generates a cryptographically random code_verifier.
  2. Derive code_challenge: A code_challenge is derived from the code_verifier using SHA256 and Base64Url encoding.
  3. Initiate Authorization: Your application redirects the user to the Identity Provider (IdP)'s authorization endpoint, including the code_challenge and code_challenge_method (S256).
  4. User Authentication & Consent: The user authenticates with the IdP and grants consent.
  5. Redirect with Authorization Code: The IdP redirects the user back to your application's specified redirect_uri with an authorization_code.
  6. Exchange Code for Tokens: Your frontend sends the authorization_code and the original code_verifier (crucially, not the code_challenge) to the IdP's token endpoint.
  7. Receive Tokens: The IdP verifies the code_verifier against the code_challenge it received earlier and, if valid, issues ID Token, Access Token, and Refresh Token.

Frontend Code: Initiating the Flow

Modern OIDC client libraries abstract much of this complexity. Here's a conceptual example using a generic OIDC client setup (e.g., oidc-client-ts or a similar library's API structure).

src/auth/oidc-config.ts
typescript
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';

export const oidcConfig = {
  authority: 'https://your-identity-provider.com',
  client_id: 'your-spa-client-id',
  redirect_uri: 'http://localhost:3000/auth-callback',
  post_logout_redirect_uri: 'http://localhost:3000/logged-out',
  response_type: 'code',
  scope: 'openid profile email api_scope',
  automaticSilentRenew: true,
  loadUserInfo: true,
  monitorSession: true,
  revokeAccessTokenOnSignout: true,
  silent_redirect_uri: 'http://localhost:3000/silent-refresh',
  userStore: new WebStorageStateStore({ store: window.localStorage }),
  // PKCE is automatically handled by the library for response_type 'code'
};

export const userManager = new UserManager(oidcConfig);

// In your main application component or auth service:
export const login = () => {
  userManager.signinRedirect();
};

export const logout = () => {
  userManager.signoutRedirect();
};

export const handleAuthCallback = async () => {
  try {
    const user = await userManager.signinRedirectCallback();
    console.log('User authenticated:', user);
    return user;
  } catch (error) {
    console.error('Authentication callback error:', error);
    throw error;
  }
};

export const getAccessToken = async () => {
  const user = await userManager.getUser();
  if (user && !user.expired) {
    return user.access_token;
  } else if (user && user.expired) {
    // Attempt silent renew if configured
    try {
      const renewedUser = await userManager.signinSilent();
      return renewedUser?.access_token;
    } catch (renewError) {
      console.error('Silent token renew failed:', renewError);
      // User needs to re-authenticate
      userManager.signinRedirect();
    }
  }
  return null;
};

Secure Client-Side Token Handling

Once tokens are received, their secure storage and management are paramount. The type of token dictates its handling strategy.

Access Tokens

  • Purpose: Used to access protected API resources. Short-lived (typically 5-60 minutes).
  • Storage: In-memory or within a Web Worker context. Never store Access Tokens in localStorage or sessionStorage directly, as they are vulnerable to Cross-Site Scripting (XSS) attacks. If an XSS vulnerability exists, an attacker could easily steal the token and impersonate the user.
  • Transmission: Included in the Authorization: Bearer header for every API request.

Refresh Tokens

  • Purpose: Used to obtain new Access Tokens and ID Tokens after the current Access Token expires, without requiring the user to re-authenticate.
  • Storage: HttpOnly, SameSite=Strict cookies. This is the most secure method for SPAs. By setting HttpOnly, JavaScript cannot access the cookie, mitigating XSS risks. SameSite=Strict prevents Cross-Site Request Forgery (CSRF) attacks by ensuring the cookie is only sent with requests originating from your site.
  • Usage: Sent to the IdP's token endpoint to refresh tokens. This exchange should happen in a secure backend route that then sets new HttpOnly cookies, or ideally, the OIDC library handles this within a hidden iframe for silent renewal, which can sometimes circumvent the HttpOnly cookie limitation if the IdP supports it securely.

ID Tokens

  • Purpose: Contains user identity information. Used for authentication and verifying the user's identity.
  • Storage: Similar to Access Tokens, they should be treated as sensitive data. Often parsed and their claims used to populate the UI (e.g., display user's name). The raw token itself doesn't need persistent storage on the client beyond what the OIDC library manages (often in-memory or secure session storage for the current session state).
  • Validation: Always validate the ID Token (signature, expiry, issuer, audience) upon receipt. OIDC libraries handle this automatically.

Token Storage: A Critical Decision

Storing Access Tokens in `localStorage` is a common anti-pattern. While convenient, it leaves tokens susceptible to XSS attacks. Prioritize `HttpOnly` cookies for refresh tokens and in-memory storage (or a Web Worker) for access tokens. This hybrid approach significantly enhances security.

Automatic Token Refresh (Silent Renew)

To provide a seamless user experience, your application should automatically refresh Access Tokens before they expire. OIDC libraries typically achieve this using a hidden iframe or by leveraging the Refresh Token (if stored securely).

  1. Hidden Iframe: A common strategy. The OIDC library loads a hidden iframe pointed to a special silent_redirect_uri on your IdP. This iframe silently initiates another signinRedirect call, which, because the user is already authenticated with the IdP (via their IdP session cookie), results in a new set of tokens being issued and returned to the iframe. Your main application listens for messages from this iframe to update its token state.
  2. Refresh Token Grant: If you're managing refresh tokens via HttpOnly cookies, your backend would have an endpoint that exchanges the refresh token for a new access token (and potentially a new refresh token). The frontend then fetches from this backend endpoint.
src/api/auth-interceptor.ts
typescript
import axios from 'axios';
import { userManager, login } from '../auth/oidc-config';

const apiClient = axios.create({
  baseURL: '/api',
});

apient.interceptors.request.use(async (config) => {
  const user = await userManager.getUser();
  if (user && !user.expired) {
    config.headers.Authorization = `Bearer ${user.access_token}`;
  } else if (user && user.expired) {
    // User token has expired, attempt silent renew
    try {
      const renewedUser = await userManager.signinSilent();
      if (renewedUser) {
        config.headers.Authorization = `Bearer ${renewedUser.access_token}`;
      }
    } catch (error) {
      console.error('Silent token renew failed, user needs to re-authenticate:', error);
      // Redirect to login if silent renew fails (e.g., refresh token expired)
      login(); // Re-initiate full login flow
      return Promise.reject(error); // Stop the original request
    }
  } else {
    // No user found, maybe redirect to login
    login();
    return Promise.reject(new Error('User not authenticated'));
  }
  return config;
}, (error) => {
  return Promise.reject(error);
});

export default apiClient;

Session Management & User Experience

Effective session management goes beyond just tokens. It involves understanding the user's overall state and providing a consistent experience.

User Session vs. Token Expiry

It's important to distinguish between the user's session with the IdP and the lifespan of your Access/Refresh Tokens. The IdP might keep a user logged in for days or weeks (e.g., remember me), while your Access Tokens expire much faster.

  • IdP Session: Managed by the IdP (usually via its own cookies). This allows for Single Sign-On (SSO) across applications that use the same IdP.
  • Application Session: Governed by the validity of your tokens. When Access Tokens expire, they are silently renewed. If Refresh Tokens also expire or become invalid (e.g., revoked), the user must re-authenticate with the IdP.

Logout Strategies

Logging out should invalidate all tokens and clear the user's session from both your application and the IdP.

  1. Client-Side Logout: Clear all tokens and user data stored in your frontend application. This is essential.
  2. IdP Logout (end_session_endpoint): Redirect the user to the IdP's end_session_endpoint (as defined in the OIDC discovery document). This invalidates the user's session at the IdP, ensuring proper SSO logout.
  • post_logout_redirect_uri: After logging out from the IdP, the user is redirected back to your application at this specified URI.
  • id_token_hint: Often, the IdP requires the id_token_hint parameter during logout to ensure the correct user's session is terminated, especially in multi-user browser scenarios.
src/components/AuthStatus.tsx
typescript
import React, { useEffect, useState } from 'react';
import { userManager, logout } from '../auth/oidc-config';
import { User } from 'oidc-client-ts';

const AuthStatus: React.FC = () => {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const loadUser = async () => {
      try {
        const currentUser = await userManager.getUser();
        setUser(currentUser);
      } catch (error) {
        console.error('Failed to load user:', error);
      } finally {
        setIsLoading(false);
      }
    };
    loadUser();

    userManager.events.addUserLoaded((loadedUser) => setUser(loadedUser));
    userManager.events.addUserUnloaded(() => setUser(null));
    userManager.events.addAccessTokenExpired(() => console.log('Access token expired. Attempting silent renew...'));
    userManager.events.addAccessTokenExpiring(() => console.log('Access token expiring soon...'));

    return () => {
      userManager.events.removeUserLoaded((loadedUser) => setUser(loadedUser));
      userManager.events.removeUserUnloaded(() => setUser(null));
    };
  }, []);

  if (isLoading) {
    return <div>Loading authentication status...</div>;
  }

  return (
    <div>
      {user ? (
        <>
          <p>Welcome, {user.profile.name || user.profile.email}!</p>
          <button onClick={() => logout()}>Log Out</button>
        </>
      ) : (
        <p>You are not logged in.</p>
      )}
    </div>
  );
};

export default AuthStatus;

Graceful Token Expiry Handling

Implement UI feedback (e.g., a toast notification) when a silent renew fails and a full re-login is required. This helps users understand why they're being redirected to the login page.

Tenant-Aware User Experience

For SaaS applications or platforms serving multiple organizations, a multi-tenant architecture is common. This means your application needs to adapt its behavior and UI based on the specific tenant the user belongs to. Frontend authentication plays a role here by ensuring the correct tenant context is established from the start.

Identifying the Tenant

There are several ways to determine the tenant context on the frontend:

  • Subdomain Routing: Each tenant has a unique subdomain (e.g., tenantA.yourapp.com, tenantB.yourapp.com). This is often the cleanest and most robust method. The frontend can parse the window.location.hostname to extract the tenant identifier.
  • Path-Based Routing: The tenant ID is part of the URL path (e.g., yourapp.com/tenantA/dashboard). Less common for root-level tenant differentiation but can be used for nested resources.
  • User Selection: If a user belongs to multiple tenants, they might select their active tenant after logging in. The selected tenant ID is then stored (e.g., in a cookie or user profile) and used for subsequent requests.

Passing Tenant Context to the IdP

When initiating the login flow, you might need to inform the IdP which tenant the user is trying to log into. This is crucial for IdPs that support isolated login experiences per tenant (e.g., custom branding, specific identity providers for that tenant).

  • login_hint or acr_values: Some IdPs support custom parameters in the authorization request to hint at the tenant. Check your IdP's documentation.
  • Custom Authorization Parameters: You might pass a tenant_id or similar parameter to the IdP's authorization endpoint. The IdP can then use this to display a tenant-specific login page or redirect to a tenant-specific external IdP.

Displaying Tenant Information in the UI

Once authenticated, the frontend should clearly indicate the active tenant.

  • ID Token Claims: The IdP can include a tenant_id or organization_id claim within the ID Token. Your frontend can read this claim and use it to render tenant-specific UI elements.
  • User Profile API: After authentication, the frontend can make an API call to your backend to fetch more detailed tenant information based on the user's ID or the tenant_id from the token.

Example: Tenant-aware Redirection

If using subdomain routing, your redirect_uri might need to be dynamic to maintain the tenant context after authentication.

src/auth/tenant-aware-oidc.ts
typescript
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';

const getTenantIdFromHostname = () => {
  const parts = window.location.hostname.split('.');
  if (parts.length > 2 && parts[0] !== 'www') {
    return parts[0]; // e.g., 'tenantA' from 'tenantA.yourapp.com'
  }
  return null;
};

const tenantId = getTenantIdFromHostname();
const baseUrl = window.location.origin;

// Dynamic redirect_uri based on tenant
const redirectUri = tenantId ?
  `${baseUrl}/auth-callback?tenant=${tenantId}` :
  `${baseUrl}/auth-callback`;

export const tenantOidcConfig = {
  authority: 'https://your-identity-provider.com',
  client_id: 'your-spa-client-id',
  redirect_uri: redirectUri,
  post_logout_redirect_uri: `${baseUrl}/logged-out`,
  response_type: 'code',
  scope: 'openid profile email api_scope tenant_id',
  automaticSilentRenew: true,
  loadUserInfo: true,
  monitorSession: true,
  revokeAccessTokenOnSignout: true,
  silent_redirect_uri: `${baseUrl}/silent-refresh`,
  userStore: new WebStorageStateStore({ store: window.localStorage }),
  extraQueryParams: tenantId ? { tenant: tenantId } : {},
};

export const tenantUserManager = new UserManager(tenantOidcConfig);

export const loginTenant = (customTenantId?: string) => {
  const activeTenant = customTenantId || tenantId;
  if (activeTenant) {
    // Some IdPs use 'organization' or 'realm' as the hint
    tenantUserManager.signinRedirect({ extraQueryParams: { tenant: activeTenant } });
  } else {
    tenantUserManager.signinRedirect();
  }
};

// Example of how to handle the callback with tenant context
export const handleAuthCallbackTenant = async () => {
  try {
    const user = await tenantUserManager.signinRedirectCallback();
    console.log('User authenticated for tenant:', user.profile.tenant || 'N/A', user);
    return user;
  } catch (error) {
    console.error('Tenant authentication callback error:', error);
    throw error;
  }
};

Tenant ID as a Security Measure

While the frontend can *read* the tenant ID, **never trust tenant IDs solely from client-side input for authorization**. Always re-verify the tenant ID against the authenticated user's claims in the **Access Token** on your backend API calls. The tenant ID is for UX purposes, not security enforcement.

Advanced Security Considerations for Frontend OAuth/OIDC

Even with PKCE and secure token handling, several other security aspects deserve attention.

  • CSRF Protection: HttpOnly SameSite=Strict cookies are excellent for protecting refresh tokens. For other actions, implement CSRF tokens (e.g., using a double-submit cookie pattern) on state-changing API requests.
  • XSS Protection: Beyond token storage, ensure all user-generated content is properly sanitized to prevent XSS attacks. A robust Content Security Policy (CSP) can also significantly reduce XSS risks.
  • Deep Linking and Redirects: Ensure that all redirect_uri and post_logout_redirect_uri values are strictly whitelisted at your IdP. Avoid open redirects that could be exploited by attackers.
  • Origin Validation: When handling callbacks, verify that the state parameter matches what was sent in the initial authorization request. OIDC libraries typically handle this, but it's crucial for preventing CSRF and mix-up attacks.
  • Error Handling: Gracefully handle authentication errors (e.g., user denies consent, invalid scopes) and provide informative feedback to the user without leaking sensitive information.
  • Session Monitoring: Some OIDC libraries offer session monitoring, allowing your application to react to changes in the IdP session (e.g., user logs out directly from the IdP). This typically involves polling a check session endpoint or using an iframe with event listeners.

Conclusion

Implementing OAuth 2.0 and OIDC on the frontend is a complex but crucial task for modern web applications. By adhering to best practices like the Authorization Code Flow with PKCE, secure token storage (HttpOnly cookies for refresh tokens, in-memory for access tokens), and robust session management, you can build a highly secure and user-friendly authentication system.

Furthermore, by thoughtfully integrating tenant-awareness from the authentication flow through to the user interface, you empower your application to serve diverse organizational needs without compromising security or usability. Remember that security is an ongoing process, and staying updated with the latest best practices and your IdP's capabilities is key to maintaining a resilient authentication architecture.

Enjoyed this deep dive?

Share:
Tags:OAuth2.0OIDCAuthenticationFrontendSecuritySingle Page Application

Discussion & Feedback

0 thoughts shared by the community

💬

Loading discussion thread...