Implementing Authentication in Modern React
JWT, sessions, OAuth, and magic links — a practical guide to choosing and implementing authentication in React apps without the security mistakes I see in client codebases.
Authentication is the feature every client wants done quickly and every security audit reveals done wrong. I’ve inherited React codebases where JWTs lived in localStorage, refresh tokens never rotated, and “social login” was a button that posted credentials to a third-party iframe. The developers weren’t careless — they followed outdated tutorials and copied patterns that were never safe.
Modern React authentication isn’t about picking between Auth0, Clerk, or rolling your own. It’s about understanding what you’re protecting, where tokens live, how sessions expire, and what happens when a user opens your app in three tabs and logs out in one.
This guide reflects how I implement auth on client projects today — pragmatic, secure defaults, and honest trade-offs between managed services and custom solutions.
Choosing Your Authentication Strategy
Before writing code, answer three questions:
- Who are your users? B2B SaaS with SSO requirements needs different architecture than a consumer app with Google login.
- What’s your backend? Serverless functions, a monolithic API, and edge-rendered pages each constrain token storage options.
- What’s your threat model? A internal admin dashboard and a public-facing fintech app have different security bars.
Here’s how I map requirements to approaches:
| Scenario | Recommended approach |
|---|---|
| MVP / startup speed | Managed auth (Clerk, Auth.js, Supabase Auth) |
| B2B with SAML/OIDC | Auth0, WorkOS, or Clerk Enterprise |
| Full control, existing API | HttpOnly cookie sessions |
| Mobile + web shared backend | Short-lived JWT + refresh rotation |
| Passwordless consumer app | Magic links or OTP via email |
The mistake I see most often: choosing JWT in localStorage because “it’s simpler.” It’s simpler until you get XSS’d and every user’s session is compromised.
Session-Based Auth with HttpOnly Cookies
For apps I control end-to-end (Next.js API routes or a dedicated backend), cookie sessions are my default. The browser sends cookies automatically, JavaScript can’t read them, and server-side validation is straightforward.
Server: create session on login
// app/api/auth/login/route.ts
import { SignJWT } from "jose";
import { cookies } from "next/headers";
const SECRET = new TextEncoder().encode(process.env.SESSION_SECRET!);
export async function POST(req: Request) {
const { email, password } = await req.json();
const user = await validateCredentials(email, password);
if (!user) {
return Response.json({ error: "Invalid credentials" }, { status: 401 });
}
const token = await new SignJWT({ sub: user.id, role: user.role })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("7d")
.setIssuedAt()
.sign(SECRET);
cookies().set("session", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
return Response.json({ user: sanitizeUser(user) });
}
Server: validate session on protected routes
// lib/auth.ts
import { jwtVerify } from "jose";
import { cookies } from "next/headers";
export async function getSession() {
const token = cookies().get("session")?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, SECRET);
return payload as { sub: string; role: string };
} catch {
return null;
}
}
export async function requireAuth() {
const session = await getSession();
if (!session) throw new AuthError("Unauthorized");
return session;
}
Client: auth context without storing tokens
"use client";
import { createContext, useContext, useEffect, useState } from "react";
interface User {
id: string;
email: string;
name: string;
role: string;
}
interface AuthContextValue {
user: User | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetch("/api/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data) => setUser(data?.user ?? null))
.finally(() => setIsLoading(false));
}, []);
const login = async (email: string, password: string) => {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error("Login failed");
const { user } = await res.json();
setUser(user);
};
const logout = async () => {
await fetch("/api/auth/logout", {
method: "POST",
credentials: "include",
});
setUser(null);
};
return (
<AuthContext.Provider value={{ user, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
The React app never touches the token. It asks the server “who am I?” and trusts the response. This eliminates an entire class of XSS token theft vulnerabilities.
Protected Routes in React and Next.js
Client-side route protection alone is insufficient — it’s UX, not security. Always enforce auth on the server for data and API routes. Client guards prevent flashing unauthorized content and improve experience.
Next.js App Router middleware:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { jwtVerify } from "jose";
const PUBLIC_PATHS = ["/", "/login", "/signup", "/pricing"];
const SECRET = new TextEncoder().encode(process.env.SESSION_SECRET!);
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
const token = req.cookies.get("session")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", req.url));
}
try {
await jwtVerify(token, SECRET);
return NextResponse.next();
} catch {
return NextResponse.redirect(new URL("/login", req.url));
}
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*", "/api/protected/:path*"],
};
Client-side guard for loading states:
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !user) {
router.replace("/login");
}
}, [user, isLoading, router]);
if (isLoading) return <AuthSkeleton />;
if (!user) return null;
return <>{children}</>;
}
OAuth and Social Login
Social login reduces friction but adds provider-specific edge cases. Whether you use Auth.js (NextAuth), Clerk, or a custom OIDC flow, the frontend pattern is similar:
export function SocialLoginButtons() {
return (
<div className="social-login">
<button
type="button"
onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
>
Continue with Google
</button>
<button
type="button"
onClick={() => signIn("github", { callbackUrl: "/dashboard" })}
>
Continue with GitHub
</button>
</div>
);
}
Real scenario from a client project: Users signed up with Google, then tried to log in with email/password and got “account not found.” We added account linking — if email matches an existing OAuth account, prompt to link or use the original provider. The UX copy matters: “You previously signed in with Google. Continue with Google or set a password?”
Always handle OAuth errors on the callback page. Providers fail silently or redirect with error query params that users never see if you don’t render them:
// app/login/page.tsx
export default function LoginPage({
searchParams,
}: {
searchParams: { error?: string };
}) {
const errorMessages: Record<string, string> = {
OAuthAccountNotLinked:
"This email is linked to another sign-in method.",
AccessDenied: "Access was denied. Please try again.",
};
const error = searchParams.error
? errorMessages[searchParams.error] ?? "Sign in failed."
: null;
return (
<div>
{error && <Alert variant="error">{error}</Alert>}
<LoginForm />
<SocialLoginButtons />
</div>
);
}
Magic Links and Passwordless Auth
Passwordless auth suits consumer apps and reduces support load from password resets. The frontend flow:
- User enters email
- Server sends magic link
- User clicks link → server validates token → creates session → redirects to app
export function MagicLinkForm() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsSubmitting(true);
await fetch("/api/auth/magic-link", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
// Always show success — don't reveal if email exists
setSent(true);
setIsSubmitting(false);
}
if (sent) {
return (
<p>Check your email for a sign-in link. It expires in 15 minutes.</p>
);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Sending..." : "Send magic link"}
</button>
</form>
);
}
Security note: Never confirm whether an email exists in the system. Always show “check your email” to prevent account enumeration.
Magic link tokens must be single-use, short-lived (15 minutes max), and cryptographically random. Validate server-side, create session, invalidate token immediately.
Token Refresh and Session Expiry
Long-lived sessions need refresh strategies. With HttpOnly cookies, I handle refresh transparently:
// middleware or API wrapper
async function refreshSessionIfNeeded(token: string) {
const { payload } = await jwtVerify(token, SECRET);
const expiresAt = (payload.exp ?? 0) * 1000;
const refreshThreshold = 24 * 60 * 60 * 1000; // 24 hours
if (expiresAt - Date.now() < refreshThreshold) {
const newToken = await new SignJWT({ sub: payload.sub, role: payload.role })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("7d")
.setIssuedAt()
.sign(SECRET);
cookies().set("session", newToken, { /* same options */ });
}
}
On the client, handle 401 responses globally:
// lib/api-client.ts
async function apiFetch(url: string, options?: RequestInit) {
const res = await fetch(url, { ...options, credentials: "include" });
if (res.status === 401) {
window.location.href = "/login?expired=1";
throw new AuthError("Session expired");
}
return res;
}
Show a friendly message when redirected with ?expired=1 — “Your session expired. Please sign in again.” Small UX detail, big reduction in confused support emails.
Role-Based Access Control on the Frontend
Backend enforces permissions. Frontend reflects them for UX — hiding admin nav items, disabling unauthorized actions, showing upgrade prompts.
type Role = "user" | "admin" | "vendor";
const PERMISSIONS: Record<Role, string[]> = {
user: ["read:profile", "write:profile"],
vendor: ["read:profile", "write:profile", "manage:products", "read:orders"],
admin: ["*"],
};
function hasPermission(role: Role, permission: string): boolean {
const perms = PERMISSIONS[role];
return perms.includes("*") || perms.includes(permission);
}
export function Can({
permission,
children,
fallback = null,
}: {
permission: string;
children: React.ReactNode;
fallback?: React.ReactNode;
}) {
const { user } = useAuth();
if (!user || !hasPermission(user.role as Role, permission)) {
return <>{fallback}</>;
}
return <>{children}</>;
}
Usage:
<Can permission="manage:products">
<Link href="/dashboard/products/new">Add product</Link>
</Can>
Never rely on this for security. A user can bypass hidden UI via DevTools. Every API endpoint validates permissions independently.
Multi-Tab and Logout Synchronization
When a user logs out in one tab, other tabs should reflect that. I use the Broadcast Channel API:
const authChannel = new BroadcastChannel("auth");
export function AuthProvider({ children }: { children: React.ReactNode }) {
// ... existing state
useEffect(() => {
authChannel.onmessage = (event) => {
if (event.data.type === "LOGOUT") {
setUser(null);
}
if (event.data.type === "LOGIN") {
setUser(event.data.user);
}
};
return () => authChannel.close();
}, []);
const logout = async () => {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
setUser(null);
authChannel.postMessage({ type: "LOGOUT" });
};
// ...
}
Without this, users log out, switch tabs, click a button, and get confusing error states instead of a clean redirect to login.
Common Authentication Mistakes
- Storing JWTs in localStorage — any XSS exposes all sessions. Use HttpOnly cookies.
- Client-only route protection — attackers bypass React guards and hit APIs directly.
- No CSRF protection on cookie auth — use SameSite=Lax minimum; CSRF tokens for state-changing forms if SameSite=None.
- Leaking user existence — “email not found” on login enables enumeration. Use generic error messages.
- Infinite session lifetime — set reasonable expiry and refresh thresholds.
- Trusting client-sent user IDs — always derive identity from validated session server-side.
- Ignoring OAuth edge cases — account linking, provider downtime, revoked permissions.
When to Use a Managed Auth Provider
I reach for Clerk or Auth.js when:
- The client needs social login, MFA, and org management quickly
- The team has no backend developer for session infrastructure
- Compliance requirements (SOC 2) favor audited third-party auth
I build custom cookie sessions when:
- The app has an existing API with its own user model
- Token formats must match a mobile app backend
- Auth costs at scale exceed engineering investment
Honest pitch to clients: managed auth saves four to six weeks on an MVP. Custom auth gives control and avoids per-MAU pricing. Neither is universally correct.
Conclusion
Authentication in modern React is a server-first problem with client-side UX responsibilities. HttpOnly cookies, middleware enforcement, and auth contexts that never touch tokens form the baseline I implement on every project. OAuth, magic links, and RBAC layer on top of that foundation — they don’t replace it.
When a client asks me to “just add login,” I scope session management, protected API routes, logout sync, and error states — not just a login form. That’s the difference between auth that ships and auth that survives production.
Key Takeaways
- Prefer HttpOnly cookie sessions over localStorage JWTs for browser-based React apps you control.
- Enforce auth on the server via middleware and API validation; client guards are UX only.
- Handle OAuth errors and account linking explicitly — social login fails in predictable ways.
- Magic links must not reveal account existence and tokens must be single-use and short-lived.
- Implement session refresh and 401 handling so expired sessions fail gracefully.
- Use RBAC components for UX, never as the security boundary.
- Sync auth state across tabs with Broadcast Channel to prevent confusing multi-tab behavior.