Skip to main content
Engineering Reference · 2026

Authentication & Authorization Architecture

A production-grade handbook covering session architecture, JWT, OAuth 2.0 with PKCE, refresh token flows, the BFF pattern, cookie security, RBAC, enterprise SSO, and secure frontend auth patterns for modern React applications.

~60 min readIntermediate to AdvancedJWT · OAuth · BFF · RBAC · 2026
01 / Introduction

Auth as an Architectural Concern

Most frontend engineers first encounter auth as a feature request: “add login.” It feels like a contained task. In practice, authentication and authorization shape every layer of your system, from cookie policy to API design to deployment model, and wrong decisions made early are expensive to reverse.

Choose the wrong token storage strategy and you introduce XSS vectors that expose every user session. Choose the wrong session model and horizontal scaling becomes impossible without shared infrastructure. Choose the wrong authorization granularity and you build yourself into a corner when business rules evolve. Auth is not a feature you bolt on. It is an architectural constraint you design around.

The most important rule in frontend auth: the frontend is a convenience layer. It shows or hides UI elements based on what a user is allowed to do. The API is the authority. Every permission check that matters must happen server-side. A user who bypasses your frontend permission check and hits the API directly must receive a 403, not data.

What This Guide Covers

  • Session architecture: stateful sessions, session stores, and when server-side sessions are the right model.
  • JWT architecture: token structure, signing algorithms, claims, access and refresh token patterns, and what never to put in a token.
  • OAuth 2.0 and OIDC: the Authorization Code + PKCE flow, token exchange, and Auth.js (NextAuth v5) configuration for production.
  • Refresh token flows: token rotation, silent refresh, and interceptor-based automatic token renewal.
  • BFF pattern: why SPAs should not hold tokens, and how a Backend for Frontend eliminates that attack surface.
  • Cookie security: httpOnly, Secure, SameSite, CSRF protection, and cookie prefixes.
  • RBAC and route guards: permission modeling, frontend permission hooks, Next.js middleware, and React Router protected routes.
  • Enterprise auth: SSO, SAML, Auth0, Okta, Cognito, multi-tenancy.
02 / Core Concepts

Authentication vs Authorization

Authentication (AuthN) answers “Who are you?” Authorization (AuthZ) answers “What are you allowed to do?” These are separate concerns with separate mechanisms, separate failure modes, and separate locations in your system. Conflating them is one of the most common sources of auth bugs in production codebases.

DimensionAuthentication (AuthN)Authorization (AuthZ)
QuestionWho are you?What can you do?
MechanismPassword, SSO, passkey, OAuthRoles, scopes, policies, claims
ResultIdentity: user ID, email, claimsDecision: allow or deny
Where it happensLogin flow, token issuanceEvery protected resource access
Failure mode401 Unauthorized403 Forbidden
Who can be trickedThe identity providerThe resource server
HTTP status codes matter here. A 401 means the request lacks valid credentials (the user is not authenticated). A 403 means the request is authenticated but not authorized. Mixing these in your API sends incorrect signals to clients and security tools alike.

Identity vs Session vs Token

Three terms that often get conflated in frontend auth discussions:

  • Identity: the canonical record of who a user is. Lives in a database. Has a stable ID, email, name, and profile data.
  • Session: a time-bounded association between an identity and an active client connection. Created on login, destroyed on logout or expiry. Can be stored server-side (stateful) or encoded into a self-describing token (stateless).
  • Token: a credential that proves a claim. Access tokens prove permission to call an API. ID tokens carry identity assertions. Refresh tokens allow renewing access tokens without re-authenticating.
03 / Session Architecture

Stateful Session Architecture

In a stateful session model, the server owns the session state. On login, the server creates a session record in a store (typically Redis), generates a random session ID, and sends that ID to the client in an httpOnly cookie. On every subsequent request, the client sends the cookie, the server looks up the session by ID, and either continues or rejects. The token the client holds is opaque: it reveals nothing about the user.

Advantages

  • Instant revocation: delete from store, session is gone
  • No sensitive data on the client
  • Session data can be large without cookie size concerns
  • Easy to inspect and audit active sessions
  • Works well with server-rendered apps (Next.js, Remix)

Tradeoffs

  • Requires shared session store for horizontal scaling
  • Redis adds operational complexity and a latency hop
  • Not naturally portable across microservices
  • Session store becomes a single point of failure
tslib/session.ts: session configuration with iron-session
import { IronSessionOptions } from "iron-session";

export interface SessionData {
  userId: string;
  email: string;
  roles: string[];
  isLoggedIn: boolean;
}

export const sessionOptions: IronSessionOptions = {
  password: process.env.SESSION_SECRET as string, // 32+ chars
  cookieName: "__Host-app-session",
  cookieOptions: {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 7, // 7 days
    path: "/",
  },
};
tsapp/api/auth/login/route.ts: session creation on login
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { sessionOptions, SessionData } from "@/lib/session";
import { verifyCredentials } from "@/lib/db";

export async function POST(req: Request) {
  const { email, password } = await req.json();

  const user = await verifyCredentials(email, password);
  if (!user) {
    return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
  }

  const session = await getIronSession<SessionData>(cookies(), sessionOptions);
  session.userId = user.id;
  session.email = user.email;
  session.roles = user.roles;
  session.isLoggedIn = true;
  await session.save();

  return NextResponse.json({ ok: true });
}
iron-session vs Redis:iron-session stores session data encrypted inside the cookie itself (stateless from the server's perspective) while still using httpOnly cookies. This eliminates the session store dependency at the cost of losing instant revocation. For apps that need instant revocation (financial, high security), use a server-side store like Redis with connect-redis or a purpose-built session service.

Redis-Backed Sessions

tslib/redis-session.ts: Redis session store setup
import { createClient } from "redis";
import RedisStore from "connect-redis";
import session from "express-session";

const redisClient = createClient({
  url: process.env.REDIS_URL,
});
redisClient.connect();

export const sessionMiddleware = session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET as string,
  resave: false,
  saveUninitialized: false,
  name: "__Host-session-id",
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days in ms
  },
});

// Revoke a session by ID (for logout, account deletion, or security events)
export async function revokeSession(sessionId: string): Promise<void> {
  await redisClient.del(`sess:${sessionId}`);
}
04 / JWT Architecture

JWT Architecture

A JSON Web Token (JWT) is a compact, self-describing credential. It consists of three base64url-encoded parts separated by dots: header.payload.signature. The header declares the algorithm. The payload carries claims. The signature proves integrity. Because the token is self-describing, any service that can verify the signature can trust its contents without querying a central store.

Standard Claims

tstypes/jwt.ts: typed JWT payload
export interface JWTPayload {
  // Standard claims (RFC 7519)
  iss: string;      // Issuer: who created the token ("https://api.myapp.com")
  sub: string;      // Subject: who the token is about (user ID)
  aud: string;      // Audience: who the token is for ("https://app.myapp.com")
  exp: number;      // Expiry: Unix timestamp when the token expires
  iat: number;      // Issued at: Unix timestamp when the token was created
  jti: string;      // JWT ID: unique identifier for this token (for revocation)

  // Custom application claims
  email: string;
  roles: string[];
  permissions: string[];
  tenantId?: string;
}

// Claims that MUST be validated on every request
export const REQUIRED_VALIDATIONS = ["iss", "aud", "exp", "jti"] as const;
Never store sensitive data in JWT payloads. The payload is base64url-encoded, not encrypted. Anyone who holds the token can decode and read the payload without knowing the signing key. Keep payloads small: user ID, roles, and scopes. Never include passwords, PII beyond email, payment data, or secrets.

Signing Algorithms: RS256 vs HS256

AlgorithmTypeKey UsageBest ForRisk if Key Leaks
HS256Symmetric (HMAC)Same secret for sign and verifySingle service, simple setupAnyone can forge tokens
RS256Asymmetric (RSA)Private key signs, public key verifiesMicroservices, third-party verificationPrivate key: forgery. Public key: no risk
ES256Asymmetric (ECDSA)Private key signs, public key verifiesHigh-performance with smaller keysSame as RS256, smaller key size

For production systems with multiple services, RS256 or ES256 is the right choice. The auth server holds the private key. Every downstream service can fetch the public key from a JWKS (JSON Web Key Set) endpoint and verify tokens independently, with no need to share a secret.

tslib/jwt.ts: JWT generation and verification
import { SignJWT, jwtVerify, generateKeyPair } from "jose";

// Generate RSA key pair (do this once, store securely)
// const { publicKey, privateKey } = await generateKeyPair("RS256");

const ISSUER = process.env.JWT_ISSUER as string;
const AUDIENCE = process.env.JWT_AUDIENCE as string;

export async function signAccessToken(payload: {
  sub: string;
  email: string;
  roles: string[];
  permissions: string[];
  jti: string;
}): Promise<string> {
  const privateKey = await importPrivateKey(process.env.JWT_PRIVATE_KEY as string);

  return new SignJWT(payload)
    .setProtectedHeader({ alg: "RS256" })
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE)
    .setIssuedAt()
    .setExpirationTime("15m") // Short-lived access token
    .sign(privateKey);
}

export async function verifyAccessToken(token: string): Promise<JWTPayload> {
  const publicKey = await importPublicKey(process.env.JWT_PUBLIC_KEY as string);

  const { payload } = await jwtVerify(token, publicKey, {
    issuer: ISSUER,
    audience: AUDIENCE,
    algorithms: ["RS256"],
  });

  return payload as unknown as JWTPayload;
}

async function importPrivateKey(pem: string): Promise<CryptoKey> {
  const { importPKCS8 } = await import("jose");
  return importPKCS8(pem, "RS256");
}

async function importPublicKey(pem: string): Promise<CryptoKey> {
  const { importSPKI } = await import("jose");
  return importSPKI(pem, "RS256");
}
05 / OAuth 2.0 and OIDC

OAuth 2.0 and OpenID Connect

OAuth 2.0 is an authorization framework, not an authentication protocol. It defines how a client can obtain limited access to a resource on behalf of a user. OpenID Connect (OIDC) is an authentication layer built on top of OAuth 2.0: it adds the id_token (a JWT containing identity claims) and a standard UserInfo endpoint, giving you authentication alongside authorization.

The implicit flow is deprecated. Older OAuth guides show SPAs using the implicit flow (tokens returned directly in the URL fragment). This was deprecated in OAuth 2.0 Security Best Current Practice (RFC 9700) due to token leakage in browser history and referrer headers. The only correct flow for public clients (SPAs, mobile apps) in 2026 is Authorization Code + PKCE.

Authorization Code + PKCE Flow

  1. App generates PKCE pair: a random code_verifier (43-128 chars) and a code_challenge (SHA-256 hash of the verifier, base64url-encoded).
  2. Redirect to provider: the app redirects to the authorization endpoint with response_type=code, code_challenge_method=S256, and the code_challenge.
  3. User authenticates: the provider shows a login screen. The user grants consent. The provider redirects back with a short-lived authorization_code.
  4. Token exchange: the app exchanges the code and the original code_verifier for an access_token, refresh_token, and id_token. The provider verifies the verifier matches the challenge before issuing tokens.
  5. PKCE prevents interception: an attacker who intercepts the code cannot exchange it without the verifier, which was never sent over the network.
tslib/auth.ts: Auth.js (NextAuth v5) configuration
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/lib/db";
import { verifyCredentials } from "@/lib/users";

export const { handlers, signIn, signOut, auth } = NextAuth({
  adapter: DrizzleAdapter(db),
  providers: [
    Google({
      clientId: process.env.AUTH_GOOGLE_ID!,
      clientSecret: process.env.AUTH_GOOGLE_SECRET!,
      authorization: {
        params: {
          scope: "openid email profile",
          prompt: "consent",
          access_type: "offline", // request refresh token
        },
      },
    }),
    GitHub({
      clientId: process.env.AUTH_GITHUB_ID!,
      clientSecret: process.env.AUTH_GITHUB_SECRET!,
    }),
    Credentials({
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      authorize: async (credentials) => {
        const user = await verifyCredentials(
          credentials.email as string,
          credentials.password as string
        );
        return user ?? null;
      },
    }),
  ],
  callbacks: {
    jwt({ token, user, trigger, session }) {
      if (user) {
        // Called on sign in: enrich token with custom claims
        token.roles = (user as { roles?: string[] }).roles ?? [];
        token.permissions = (user as { permissions?: string[] }).permissions ?? [];
      }
      if (trigger === "update" && session) {
        // Called when session.update() is triggered
        token.roles = session.roles;
      }
      return token;
    },
    session({ session, token }) {
      session.user.roles = token.roles as string[];
      session.user.permissions = token.permissions as string[];
      return session;
    },
  },
  session: {
    strategy: "jwt",
    maxAge: 30 * 24 * 60 * 60, // 30 days
  },
  pages: {
    signIn: "/login",
    error: "/login",
  },
});
tsapp/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";

export const { GET, POST } = handlers;
06 / Refresh Token Flows

Refresh Token Flows and Token Rotation

Access tokens should be short-lived: 15 minutes is a reasonable default for most applications. A short TTL limits the damage window if a token is stolen. But asking users to re-authenticate every 15 minutes is not acceptable UX. Refresh tokens solve this: they are long-lived credentials (7-30 days) that the client can use to silently obtain a new access token without user interaction.

Token TypeTypical TTLWhere StoredWhat It Does
Access Token15 minMemory (SPA) or httpOnly cookie (BFF)Sent with every API request
Refresh Token7-30 dayshttpOnly cookie (always)Exchanges for new access token
ID TokenSession-scopedMemory or server sessionCarries identity claims (OIDC)

Refresh Token Rotation

Token rotation means every refresh operation issues a new refresh token and invalidates the old one. If an attacker steals a refresh token and tries to use it after the legitimate client has already rotated it, the attempt will fail and the auth server can detect the anomaly. Some implementations detect this as a token reuse attack and revoke the entire token family, logging the user out.

tslib/token-client.ts: Axios interceptor with automatic token refresh
import axios from "axios";

const api = axios.create({ baseURL: "/api" });

let refreshPromise: Promise<string> | null = null;

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const original = error.config;

    // 401 on a request that has not been retried yet
    if (error.response?.status === 401 && !original._retry) {
      original._retry = true;

      // Deduplicate concurrent refresh attempts
      if (!refreshPromise) {
        refreshPromise = refreshAccessToken().finally(() => {
          refreshPromise = null;
        });
      }

      try {
        const newAccessToken = await refreshPromise;
        // Store in memory only, never localStorage
        tokenStore.set(newAccessToken);
        original.headers["Authorization"] = `Bearer ${newAccessToken}`;
        return api(original);
      } catch {
        // Refresh failed: redirect to login
        tokenStore.clear();
        window.location.href = "/login";
        return Promise.reject(error);
      }
    }

    return Promise.reject(error);
  }
);

async function refreshAccessToken(): Promise<string> {
  // Refresh token is in httpOnly cookie, sent automatically
  const res = await fetch("/api/auth/refresh", { method: "POST" });
  if (!res.ok) throw new Error("Refresh failed");
  const data = await res.json();
  return data.accessToken;
}

// In-memory token store: survives JS execution, not page reload
const tokenStore = {
  _token: null as string | null,
  get: () => tokenStore._token,
  set: (t: string) => { tokenStore._token = t; },
  clear: () => { tokenStore._token = null; },
};

// Attach token to every request
api.interceptors.request.use((config) => {
  const token = tokenStore.get();
  if (token) config.headers["Authorization"] = `Bearer ${token}`;
  return config;
});

export { api, tokenStore };
Silent refresh timing: schedule a silent refresh when the access token is ~80% through its TTL. For a 15-minute token, refresh after 12 minutes. Use a setTimeout based on the token's exp claim rather than polling on an interval, which wastes requests and can cause race conditions.
07 / BFF Pattern

Backend for Frontend (BFF) Pattern

The core problem with SPA authentication is token storage. Access tokens need to be sent with API requests. But browsers have no secure client-side storage. Options:

  • localStorage / sessionStorage: accessible to any JavaScript on the page. One XSS vulnerability anywhere on your domain and every user token is compromised. Never use for token storage.
  • In-memory: not accessible to XSS. But tokens are lost on page reload, requiring silent refresh on every navigation. Works for access tokens.
  • httpOnly cookie: not accessible to JavaScript at all. Sent automatically by the browser. The most secure storage for both access and refresh tokens, but requires server infrastructure to set and read them.

The BFF pattern resolves this: a server-side layer (the “Backend for Frontend”) handles all OAuth flows and token management. The SPA never sees raw tokens. Tokens live in httpOnly cookies controlled by the BFF. The SPA calls the BFF, which attaches tokens to upstream API calls and forwards the response.

tsapp/api/auth/callback/route.ts: BFF token exchange endpoint
import { NextResponse } from "next/server";
import { cookies } from "next/headers";

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const code = searchParams.get("code");
  const state = searchParams.get("state");

  // Validate state parameter against stored value (CSRF protection)
  const cookieStore = cookies();
  const storedState = cookieStore.get("oauth_state")?.value;
  if (!state || state !== storedState) {
    return NextResponse.redirect("/login?error=invalid_state");
  }

  const codeVerifier = cookieStore.get("pkce_verifier")?.value;
  if (!code || !codeVerifier) {
    return NextResponse.redirect("/login?error=missing_params");
  }

  // Exchange code for tokens server-side (code_verifier never leaves server)
  const tokenRes = await fetch(process.env.AUTH_TOKEN_ENDPOINT!, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: process.env.AUTH_REDIRECT_URI!,
      client_id: process.env.AUTH_CLIENT_ID!,
      code_verifier: codeVerifier,
    }),
  });

  if (!tokenRes.ok) {
    return NextResponse.redirect("/login?error=token_exchange_failed");
  }

  const tokens = await tokenRes.json();

  const response = NextResponse.redirect("/dashboard");

  // Store tokens in httpOnly cookies - JS cannot read these
  response.cookies.set("access_token", tokens.access_token, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 15 * 60, // 15 minutes
    path: "/",
  });

  response.cookies.set("refresh_token", tokens.refresh_token, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 30 * 24 * 60 * 60, // 30 days
    path: "/api/auth/refresh", // restrict to refresh endpoint only
  });

  // Clear PKCE cookies
  response.cookies.delete("pkce_verifier");
  response.cookies.delete("oauth_state");

  return response;
}
tsapp/api/proxy/[...path]/route.ts: BFF proxy to upstream API
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { verifyAndRefreshToken } from "@/lib/token-server";

const UPSTREAM_API = process.env.UPSTREAM_API_URL!;

export async function GET(req: NextRequest, { params }: { params: { path: string[] } }) {
  return proxyRequest(req, "GET", params.path);
}

export async function POST(req: NextRequest, { params }: { params: { path: string[] } }) {
  return proxyRequest(req, "POST", params.path);
}

async function proxyRequest(req: NextRequest, method: string, path: string[]) {
  const cookieStore = cookies();
  const accessToken = cookieStore.get("access_token")?.value;

  if (!accessToken) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // Verify token and auto-refresh if expired (server-side, invisible to client)
  const validToken = await verifyAndRefreshToken(accessToken);
  if (!validToken) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const upstreamUrl = `${UPSTREAM_API}/${path.join("/")}${req.nextUrl.search}`;
  const body = method !== "GET" ? await req.text() : undefined;

  const upstream = await fetch(upstreamUrl, {
    method,
    headers: {
      "Authorization": `Bearer ${validToken}`,
      "Content-Type": req.headers.get("Content-Type") ?? "application/json",
    },
    body,
  });

  const data = await upstream.json();
  return NextResponse.json(data, { status: upstream.status });
}
08 / Cookie Security

Cookie Security Model

Cookies are the most secure transport for auth credentials in web applications when configured correctly. Every auth cookie you set in production should include four attributes: HttpOnly, Secure, SameSite, and an appropriate Path. Missing any of them is a security defect.

AttributeWhat It DoesValue in Production
HttpOnlyPrevents JavaScript access via document.cookieAlways set
SecureTransmitted over HTTPS onlyAlways set in production
SameSite=LaxSent on same-site requests + top-level navigationsDefault for most apps
SameSite=StrictSent on same-site requests onlyHigh-security apps; breaks OAuth redirect flows
SameSite=NoneSent cross-site (requires Secure)Only for cross-site use cases
PathRestricts cookie to a URL prefixScope refresh token to /api/auth/refresh
__Host- prefixRequires Secure + Path=/, no Domain attributeStrongest origin binding

CSRF Protection

SameSite=Lax protects against the most common CSRF attacks because cross-site POST requests will not include the cookie. For state-changing requests from forms or AJAX, Lax is sufficient in most architectures. For apps using SameSite=None (cross-site embeds, iframes), add a synchronizer CSRF token as a second layer.

tslib/csrf.ts: double-submit cookie CSRF pattern
import { randomBytes, createHmac } from "crypto";

const CSRF_SECRET = process.env.CSRF_SECRET as string;

export function generateCsrfToken(sessionId: string): string {
  const random = randomBytes(32).toString("hex");
  // Bind CSRF token to the session to prevent token substitution attacks
  const hmac = createHmac("sha256", CSRF_SECRET)
    .update(`${sessionId}:${random}`)
    .digest("hex");
  return `${random}.${hmac}`;
}

export function verifyCsrfToken(token: string, sessionId: string): boolean {
  const [random, hmac] = token.split(".");
  if (!random || !hmac) return false;

  const expected = createHmac("sha256", CSRF_SECRET)
    .update(`${sessionId}:${random}`)
    .digest("hex");

  // Constant-time comparison to prevent timing attacks
  return expected.length === hmac.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(hmac));
}

function timingSafeEqual(a: Buffer, b: Buffer): boolean {
  if (a.length !== b.length) return false;
  let result = 0;
  for (let i = 0; i < a.length; i++) {
    result |= a[i] ^ b[i];
  }
  return result === 0;
}
09 / RBAC and Frontend Permissions

Role-Based Access Control and Frontend Permissions

Role-Based Access Control (RBAC) assigns permissions to roles and roles to users. It is simpler to reason about than Attribute-Based Access Control (ABAC) for most applications, and maps naturally to what frontend engineers need: checking whether the current user can take a specific action.

Frontend RBAC is UI convenience, not security. Hiding a button from a user who lacks permission is good UX. It is not a security control. A determined user can call your API directly. The API must enforce every permission check. Frontend permission logic and API permission logic should mirror each other, with the API as the authoritative enforcer.
tslib/permissions.ts: permission types and role mapping
export type Permission =
  | "posts:read"
  | "posts:write"
  | "posts:delete"
  | "users:read"
  | "users:write"
  | "users:delete"
  | "admin:all";

export type Role = "viewer" | "editor" | "admin" | "super_admin";

export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
  viewer: ["posts:read"],
  editor: ["posts:read", "posts:write", "users:read"],
  admin: ["posts:read", "posts:write", "posts:delete", "users:read", "users:write"],
  super_admin: ["posts:read", "posts:write", "posts:delete", "users:read", "users:write", "users:delete", "admin:all"],
};

export function hasPermission(userRoles: Role[], permission: Permission): boolean {
  return userRoles.some((role) => {
    const perms = ROLE_PERMISSIONS[role] ?? [];
    return perms.includes(permission) || perms.includes("admin:all");
  });
}

export function hasAnyPermission(userRoles: Role[], permissions: Permission[]): boolean {
  return permissions.some((p) => hasPermission(userRoles, p));
}

export function hasAllPermissions(userRoles: Role[], permissions: Permission[]): boolean {
  return permissions.every((p) => hasPermission(userRoles, p));
}
tsxhooks/usePermissions.ts: permission hook
import { useSession } from "next-auth/react";
import { hasPermission, hasAnyPermission, Permission, Role } from "@/lib/permissions";

export function usePermissions() {
  const { data: session } = useSession();
  const userRoles = (session?.user?.roles ?? []) as Role[];

  return {
    can: (permission: Permission) => hasPermission(userRoles, permission),
    canAny: (permissions: Permission[]) => hasAnyPermission(userRoles, permissions),
    roles: userRoles,
    isLoggedIn: !!session,
  };
}

// Usage in a component:
// const { can } = usePermissions();
// {can("posts:write") && <button>Edit Post</button>}
tsxcomponents/PermissionGuard.tsx: declarative permission wrapper
import { usePermissions } from "@/hooks/usePermissions";
import type { Permission } from "@/lib/permissions";

interface PermissionGuardProps {
  require: Permission | Permission[];
  requireAll?: boolean;
  fallback?: React.ReactNode;
  children: React.ReactNode;
}

export function PermissionGuard({
  require,
  requireAll = false,
  fallback = null,
  children,
}: PermissionGuardProps) {
  const { can, canAny } = usePermissions();
  const permissions = Array.isArray(require) ? require : [require];

  const allowed = requireAll
    ? permissions.every((p) => can(p))
    : canAny(permissions);

  return allowed ? <>{children}</> : <>{fallback}</>;
}

// Usage:
// <PermissionGuard require="posts:delete" fallback={<p>No access</p>}>
//   <DeleteButton />
// </PermissionGuard>
10 / Route Guards

Route Guards and Conditional Rendering

Route-level protection prevents unauthenticated or unauthorized users from reaching protected pages. In Next.js, the preferred location for this is middleware, which runs on the edge before the page renders and can redirect without a full server round-trip. In client-side React apps, this is handled by protected route wrappers around your router.

tsmiddleware.ts: Next.js edge middleware for auth protection
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";

const PUBLIC_PATHS = ["/", "/login", "/register", "/api/auth"];
const ADMIN_PATHS = ["/admin"];

export async function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  // Allow public paths through without auth check
  if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
    return NextResponse.next();
  }

  const session = await auth();

  // Not authenticated: redirect to login with return URL
  if (!session) {
    const loginUrl = new URL("/login", req.url);
    loginUrl.searchParams.set("callbackUrl", pathname);
    return NextResponse.redirect(loginUrl);
  }

  // Admin paths require admin role
  if (ADMIN_PATHS.some((p) => pathname.startsWith(p))) {
    const isAdmin = session.user.roles?.includes("admin") ||
      session.user.roles?.includes("super_admin");
    if (!isAdmin) {
      return NextResponse.redirect(new URL("/403", req.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  // Match all paths except static files and Next.js internals
  matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\.png$).*)"],
};
tsxcomponents/ProtectedRoute.tsx: React Router protected route
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth";
import type { Role } from "@/lib/permissions";

interface ProtectedRouteProps {
  children: React.ReactNode;
  requiredRoles?: Role[];
}

export function ProtectedRoute({ children, requiredRoles }: ProtectedRouteProps) {
  const { isAuthenticated, isLoading, user } = useAuth();
  const location = useLocation();

  if (isLoading) {
    return <LoadingScreen />;
  }

  if (!isAuthenticated) {
    // Preserve the intended destination for post-login redirect
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  if (requiredRoles && !requiredRoles.some((r) => user?.roles.includes(r))) {
    return <Navigate to="/403" replace />;
  }

  return <>{children}</>;
}

// Usage with React Router:
// <Route path="/dashboard" element={
//   <ProtectedRoute>
//     <Dashboard />
//   </ProtectedRoute>
// } />
//
// <Route path="/admin" element={
//   <ProtectedRoute requiredRoles={["admin"]}>
//     <AdminPanel />
//   </ProtectedRoute>
// } />
11 / Enterprise Auth

Enterprise Auth: SSO, SAML, and Identity Providers

Enterprise applications almost always need to integrate with an external identity provider rather than managing credentials directly. Employees already have corporate identities in systems like Azure Active Directory, Okta, or Google Workspace. SSO allows them to authenticate once and access multiple apps without re-entering credentials.

Provider / ProtocolBest ForProtocolAuth.js Support
Auth0General purpose, B2C + B2BOIDC + OAuth 2.0Built-in provider
OktaEnterprise workforce identityOIDC + SAMLBuilt-in provider
Azure AD / Entra IDMicrosoft enterprise orgsOIDC + SAMLBuilt-in (Microsoft Entra)
AWS CognitoAWS-native appsOIDC + OAuth 2.0Custom OIDC provider
ClerkModern DX, embedded UI componentsProprietary + OIDCOfficial integration
SAML 2.0Legacy enterprise IdPsSAMLVia saml2-js or BoxyHQ SAML

Multi-Tenant Auth

Multi-tenant SaaS applications need to route users to the correct IdP based on their organization. The standard pattern is an “organization slug” or email domain lookup that identifies the tenant's IdP configuration before the authentication redirect.

tslib/auth-multitenant.ts: tenant-aware Auth.js configuration
import NextAuth from "next-auth";
import { getTenantConfig } from "@/lib/tenants";

export const { handlers, auth } = NextAuth(async (req) => {
  // Determine tenant from request: subdomain, path param, or stored cookie
  const hostname = req.headers.get("host") ?? "";
  const subdomain = hostname.split(".")[0];
  const tenant = await getTenantConfig(subdomain);

  return {
    providers: tenant
      ? [
          // Dynamic OIDC provider based on tenant configuration
          {
            id: `oidc-${tenant.id}`,
            name: tenant.name,
            type: "oidc",
            issuer: tenant.oidcIssuer,
            clientId: tenant.clientId,
            clientSecret: tenant.clientSecret,
          },
        ]
      : [
          // Fallback to default providers for non-tenant requests
          defaultProviders,
        ],
    callbacks: {
      jwt({ token, user }) {
        if (user) {
          token.tenantId = tenant?.id;
          token.tenantRoles = user.tenantRoles;
        }
        return token;
      },
    },
  };
});
12 / Security Pitfalls

Security Pitfalls in Frontend Auth

Auth is an area where subtle mistakes have high consequences. The following pitfalls appear frequently in production codebases and in security audits of React applications.

Token Storage in localStorage

localStorage is synchronously readable by any script on the page. One third-party script, one XSS vulnerability, one supply chain compromise, and every user's token is exposed. Never store access tokens or refresh tokens in localStorage.

Not Validating JWT Claims

Decoding a JWT is not the same as verifying it. You must verify the signature AND validate iss, aud, exp, and jti. An attacker can forge a JWT with modified claims if you only decode without verifying.

Long-Lived Access Tokens

An access token with a 24-hour or 7-day TTL gives an attacker a large window to use a stolen token. Short TTLs (15 minutes) combined with refresh tokens give you usability without the exposure window.

Logging Token Values

Logging raw token values in application logs, error tracking (Sentry, Datadog), or analytics creates a credential exposure risk. Log token IDs (jti) for audit trails, never the token itself.

PII in JWT Payload

JWTs are base64-encoded, not encrypted. Any data in the payload is readable to anyone with the token. Keep payloads to user ID, roles, and scopes. Fetch profile data (name, email, address) from the API using the user ID.

No Token Rotation

A refresh token without rotation is a permanent credential. If stolen, an attacker can silently renew access indefinitely. Token rotation limits the window and enables detection of theft via reuse anomalies.

Clickjacking and Framing

Auth pages (login, OAuth consent) should set X-Frame-Options: DENY or Content-Security-Policy: frame-ancestors 'none' to prevent clickjacking attacks that overlay invisible login forms over legitimate content. Next.js applies these headers by default in recent versions, but verify your configuration explicitly.

13 / Anti-Patterns

Auth Anti-Patterns

Beyond the acute pitfalls, these patterns are architectural mistakes that lead to insecure systems, unmaintainable code, or both.

Client-Side Authorization Enforcement Only

Hiding a “Delete” button from users without the delete permission is correct UX. Treating that hidden button as the security control is a serious error. If your API route for deletion does not verify the requester's permissions independently, the hidden button is security theater. A user can call DELETE /api/posts/123 with their access token directly.

tsxanti-pattern: client-side auth only (insecure)
// WRONG: the API does not check permissions
// The button is hidden, but the endpoint is open to any authenticated user

function PostActions({ postId }: { postId: string }) {
  const { can } = usePermissions();
  return (
    <div>
      {can("posts:delete") && (
        <button onClick={() => deletePost(postId)}>Delete</button>
      )}
    </div>
  );
}

// The API route:
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
  const session = await auth();
  if (!session) return new Response(null, { status: 401 });
  // BUG: no permission check here - any authenticated user can delete any post
  await db.post.delete({ where: { id: params.id } });
  return new Response(null, { status: 204 });
}
tsxcorrect: permission check in both UI and API
// CORRECT: UI hides the button AND API enforces the permission

// UI layer (convenience)
function PostActions({ postId }: { postId: string }) {
  const { can } = usePermissions();
  return (
    <div>
      {can("posts:delete") && (
        <button onClick={() => deletePost(postId)}>Delete</button>
      )}
    </div>
  );
}

// API layer (security enforcement)
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
  const session = await auth();
  if (!session) return new Response(null, { status: 401 });

  const canDelete = hasPermission(session.user.roles, "posts:delete");
  if (!canDelete) return new Response(null, { status: 403 });

  await db.post.delete({ where: { id: params.id } });
  return new Response(null, { status: 204 });
}

Using the OAuth Implicit Flow

Returning tokens directly in the URL fragment (the implicit flow) is deprecated. The fragment appears in browser history, referrer headers, and server access logs. It was never the right choice for production SPAs. Always use Authorization Code with PKCE.

Trusting the Frontend's Role Claims

Your API should derive permission checks from a verified token or session, not from a role value the frontend sends in the request body or a custom header. If a client can set X-User-Role: admin and your API trusts it, every user is an admin. Role and permission information must come from the token the server issued or the session the server created.

14 / Decision Framework

Auth Strategy Decision Framework

No single auth strategy is correct for all applications. The right choice depends on your scaling requirements, security needs, team capability, and deployment architecture.

StrategyRevocationScalabilitySPA FriendlyComplexityBest For
Stateful SessionsInstantRequires shared storePossible with CORS careLowServer-rendered apps, high-security apps
JWT (stateless)Requires blocklistHorizontal scale nativeToken storage problemMediumMicroservices, stateless APIs
OAuth + PKCEVia providerProvider handles scaleDesigned for SPAsMedium-HighThird-party login, enterprise SSO
BFF + httpOnlyVia provider or storeGood with stateless BFFBest SPA securityHighHigh-security SPAs, financial apps
Auth.js (NextAuth v5)Via JWT strategyGoodNative Next.js integrationLow-MediumNext.js apps, rapid auth setup

Decision Guide

Use Sessions When:

  • You need instant revocation on logout or security events
  • Your app is primarily server-rendered (Next.js SSR, Remix)
  • You have a single application, not a distributed API surface
  • You can afford a Redis dependency for shared session state

Use JWT + BFF When:

  • You have a SPA that calls multiple APIs or microservices
  • Security requirements are high (financial, healthcare)
  • You want to eliminate token storage risk from the client entirely
  • You are deploying on serverless (Vercel, Cloudflare Workers)

Use OAuth + OIDC When:

  • You need “Login with Google/GitHub/Microsoft”
  • Your users are enterprise employees with existing corporate identities
  • You want to delegate credential management to a trusted provider
  • You need SAML federation for enterprise customers

Use Auth.js When:

  • You are building a Next.js application and want quick setup
  • You need multiple providers (credentials + OAuth + SAML) in one config
  • You want database adapter support out of the box
  • You do not need fine-grained token control beyond what Auth.js provides
15 / Without Next.js

Auth Patterns Without Next.js

Vite SPAs and other client-only frontends face a harder auth challenge than Next.js apps because they have no server layer for token exchange or secure cookie setting. The right architecture depends on whether you are willing to add a lightweight server component as a BFF.

ApproachToken StorageXSS RiskSetup ComplexityRecommended
SPA + localStoragelocalStorageHighLowNo
SPA + memory onlyJS variableLowMediumAcceptable for low-risk apps
SPA + Express BFFhttpOnly cookie via BFFLowMedium-HighYes, for production SPAs
SPA + PKCE directMemory (access) + httpOnly (refresh)MediumHighYes, with careful implementation

PKCE in a Vite SPA

tslib/pkce.ts: PKCE implementation for a Vite SPA
// PKCE helper for Authorization Code + PKCE flow in a browser SPA

function base64urlEncode(buffer: ArrayBuffer): string {
  return btoa(String.fromCharCode(...new Uint8Array(buffer)))
    .replace(/+/g, "-")
    .replace(///g, "_")
    .replace(/=/g, "");
}

export async function generatePKCE(): Promise<{
  codeVerifier: string;
  codeChallenge: string;
}> {
  const array = new Uint8Array(64);
  crypto.getRandomValues(array);
  const codeVerifier = base64urlEncode(array.buffer);

  const encoded = new TextEncoder().encode(codeVerifier);
  const hash = await crypto.subtle.digest("SHA-256", encoded);
  const codeChallenge = base64urlEncode(hash);

  return { codeVerifier, codeChallenge };
}

export function buildAuthUrl(params: {
  clientId: string;
  redirectUri: string;
  scope: string;
  state: string;
  codeChallenge: string;
}): string {
  const url = new URL(import.meta.env.VITE_AUTH_AUTHORIZATION_ENDPOINT);
  url.searchParams.set("response_type", "code");
  url.searchParams.set("client_id", params.clientId);
  url.searchParams.set("redirect_uri", params.redirectUri);
  url.searchParams.set("scope", params.scope);
  url.searchParams.set("state", params.state);
  url.searchParams.set("code_challenge", params.codeChallenge);
  url.searchParams.set("code_challenge_method", "S256");
  return url.toString();
}

export async function exchangeCodeForTokens(code: string, codeVerifier: string) {
  // In a pure SPA (no BFF), the token exchange must be public (no client_secret)
  // This is secure because the code_verifier cannot be guessed
  const res = await fetch(import.meta.env.VITE_AUTH_TOKEN_ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: import.meta.env.VITE_AUTH_REDIRECT_URI,
      client_id: import.meta.env.VITE_AUTH_CLIENT_ID,
      code_verifier: codeVerifier,
    }),
  });

  if (!res.ok) throw new Error("Token exchange failed");
  return res.json() as Promise<{
    access_token: string;
    refresh_token: string;
    id_token: string;
    expires_in: number;
  }>;
}

Express BFF for SPA Token Storage

tsserver/bff.ts: minimal Express BFF for httpOnly cookie auth
import express from "express";
import cookieParser from "cookie-parser";
import { exchangeCodeForTokens, refreshAccessToken } from "./token-service";

const app = express();
app.use(express.json());
app.use(cookieParser(process.env.COOKIE_SECRET));

const COOKIE_OPTS = {
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "lax" as const,
};

// Callback: complete the PKCE exchange and store tokens in httpOnly cookies
app.get("/auth/callback", async (req, res) => {
  const { code, state } = req.query as Record<string, string>;

  const stored = req.signedCookies.oauth_state;
  if (!state || state !== stored) {
    return res.redirect("/login?error=invalid_state");
  }

  const codeVerifier = req.signedCookies.pkce_verifier;
  const tokens = await exchangeCodeForTokens(code, codeVerifier);

  res.clearCookie("oauth_state");
  res.clearCookie("pkce_verifier");

  res.cookie("access_token", tokens.access_token, {
    ...COOKIE_OPTS,
    maxAge: tokens.expires_in * 1000,
  });
  res.cookie("refresh_token", tokens.refresh_token, {
    ...COOKIE_OPTS,
    maxAge: 30 * 24 * 60 * 60 * 1000,
    path: "/auth/refresh",
  });

  res.redirect("/");
});

// Silent refresh: SPA calls this when access token is near expiry
app.post("/auth/refresh", async (req, res) => {
  const refreshToken = req.cookies.refresh_token;
  if (!refreshToken) return res.status(401).json({ error: "No refresh token" });

  try {
    const tokens = await refreshAccessToken(refreshToken);
    res.cookie("access_token", tokens.access_token, {
      ...COOKIE_OPTS,
      maxAge: tokens.expires_in * 1000,
    });
    res.json({ ok: true, expiresIn: tokens.expires_in });
  } catch {
    res.clearCookie("access_token");
    res.clearCookie("refresh_token");
    res.status(401).json({ error: "Refresh failed" });
  }
});

app.listen(3001);

React Router Protected Routes in a Vite SPA

tsxsrc/router.tsx: TanStack Router with auth loaders
import { createRouter, createRoute, redirect } from "@tanstack/react-router";
import { rootRoute } from "./__root";
import { authStore } from "@/lib/auth-store";

const dashboardRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: "/dashboard",
  beforeLoad: async ({ location }) => {
    if (!authStore.isAuthenticated()) {
      throw redirect({
        to: "/login",
        search: { redirect: location.href },
      });
    }
  },
  component: Dashboard,
});

const adminRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: "/admin",
  beforeLoad: async () => {
    const user = authStore.getUser();
    if (!user?.roles.includes("admin")) {
      throw redirect({ to: "/403" });
    }
  },
  component: AdminPanel,
});

export const router = createRouter({
  routeTree: rootRoute.addChildren([dashboardRoute, adminRoute]),
});
Recommended stack for a production Vite SPA: use PKCE for the OAuth flow, a minimal Express or Fastify BFF to handle the token exchange and store tokens in httpOnly cookies, TanStack Router for type-safe routing with auth loaders, and TanStack Query for API calls that automatically include credentials via cookies. This gives you the security properties of a Next.js BFF without needing Next.js.