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.
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.
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.
| Dimension | Authentication (AuthN) | Authorization (AuthZ) |
|---|---|---|
| Question | Who are you? | What can you do? |
| Mechanism | Password, SSO, passkey, OAuth | Roles, scopes, policies, claims |
| Result | Identity: user ID, email, claims | Decision: allow or deny |
| Where it happens | Login flow, token issuance | Every protected resource access |
| Failure mode | 401 Unauthorized | 403 Forbidden |
| Who can be tricked | The identity provider | The resource server |
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.
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
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: "/",
},
};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 });
}connect-redis or a purpose-built session service.Redis-Backed Sessions
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}`);
}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
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;Signing Algorithms: RS256 vs HS256
| Algorithm | Type | Key Usage | Best For | Risk if Key Leaks |
|---|---|---|---|---|
| HS256 | Symmetric (HMAC) | Same secret for sign and verify | Single service, simple setup | Anyone can forge tokens |
| RS256 | Asymmetric (RSA) | Private key signs, public key verifies | Microservices, third-party verification | Private key: forgery. Public key: no risk |
| ES256 | Asymmetric (ECDSA) | Private key signs, public key verifies | High-performance with smaller keys | Same 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.
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");
}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.
Authorization Code + PKCE Flow
- App generates PKCE pair: a random
code_verifier(43-128 chars) and acode_challenge(SHA-256 hash of the verifier, base64url-encoded). - Redirect to provider: the app redirects to the authorization endpoint with
response_type=code,code_challenge_method=S256, and thecode_challenge. - User authenticates: the provider shows a login screen. The user grants consent. The provider redirects back with a short-lived
authorization_code. - Token exchange: the app exchanges the code and the original
code_verifierfor anaccess_token,refresh_token, andid_token. The provider verifies the verifier matches the challenge before issuing tokens. - PKCE prevents interception: an attacker who intercepts the code cannot exchange it without the verifier, which was never sent over the network.
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",
},
});import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;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 Type | Typical TTL | Where Stored | What It Does |
|---|---|---|---|
| Access Token | 15 min | Memory (SPA) or httpOnly cookie (BFF) | Sent with every API request |
| Refresh Token | 7-30 days | httpOnly cookie (always) | Exchanges for new access token |
| ID Token | Session-scoped | Memory or server session | Carries 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.
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 };setTimeout based on the token's exp claim rather than polling on an interval, which wastes requests and can cause race conditions.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.
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;
}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 });
}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.
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));
}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>}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>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.
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$).*)"],
};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>
// } />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 / Protocol | Best For | Protocol | Auth.js Support |
|---|---|---|---|
| Auth0 | General purpose, B2C + B2B | OIDC + OAuth 2.0 | Built-in provider |
| Okta | Enterprise workforce identity | OIDC + SAML | Built-in provider |
| Azure AD / Entra ID | Microsoft enterprise orgs | OIDC + SAML | Built-in (Microsoft Entra) |
| AWS Cognito | AWS-native apps | OIDC + OAuth 2.0 | Custom OIDC provider |
| Clerk | Modern DX, embedded UI components | Proprietary + OIDC | Official integration |
| SAML 2.0 | Legacy enterprise IdPs | SAML | Via 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.
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;
},
},
};
});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.
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.
// 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 });
}// 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.
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.
| Strategy | Revocation | Scalability | SPA Friendly | Complexity | Best For |
|---|---|---|---|---|---|
| Stateful Sessions | Instant | Requires shared store | Possible with CORS care | Low | Server-rendered apps, high-security apps |
| JWT (stateless) | Requires blocklist | Horizontal scale native | Token storage problem | Medium | Microservices, stateless APIs |
| OAuth + PKCE | Via provider | Provider handles scale | Designed for SPAs | Medium-High | Third-party login, enterprise SSO |
| BFF + httpOnly | Via provider or store | Good with stateless BFF | Best SPA security | High | High-security SPAs, financial apps |
| Auth.js (NextAuth v5) | Via JWT strategy | Good | Native Next.js integration | Low-Medium | Next.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
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.
| Approach | Token Storage | XSS Risk | Setup Complexity | Recommended |
|---|---|---|---|---|
| SPA + localStorage | localStorage | High | Low | No |
| SPA + memory only | JS variable | Low | Medium | Acceptable for low-risk apps |
| SPA + Express BFF | httpOnly cookie via BFF | Low | Medium-High | Yes, for production SPAs |
| SPA + PKCE direct | Memory (access) + httpOnly (refresh) | Medium | High | Yes, with careful implementation |
PKCE in 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
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
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]),
});