Auth in React: The Session Belongs in a Cookie, Not in Context
Six Server Actions, a 68 KiB auth SDK kept out of every initial bundle, and one line that decides the whole security posture: getClaims(), never getSession().
Most React authentication tutorials put the session in a Context provider and the login call in a useEffect. This storefront does neither. The session is a cookie the server verifies on every request, all six auth operations are Server Actions, and the 68 KiB client SDK is out of every page's initial bundle — signed-out visitors never download it at all.
Where the session lives is the whole decision
Every other question in React auth — which library, which hook, which provider — is downstream of one choice: what holds the session, and who is allowed to believe it.
| Approach | Session stored in | Verified by | Cost |
|---|---|---|---|
Context + localStorage | JavaScript memory, rehydrated on load | The client, usually not at all | XSS-readable; flashes signed-out on every load; server knows nothing |
| Client SDK + cookie | Cookie read by the SDK in the browser | The auth service, over the network | The SDK ships to every page that might need it |
| Cookie verified server-side | Cookie, refreshed by the server | The server, per request, before render | Auth becomes a server concern; the client can stay dumb |
This codebase takes the third. The practical consequence is that a signed-in page is rendered signed-in — there is no authenticated-looking flash, no isLoading gate around the whole app, and no moment where the browser holds a token the server has not checked.
The request path, in the actual file
src/proxy.ts runs on every request that is not a static asset, refreshes the session, and writes the rotated cookies onto the response:
// src/proxy.ts
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { /* read from request, write to response */ } },
);
await supabase.auth.getClaims();
return response;
}
One line in there is a security decision, not a style preference: getClaims(), never getSession(). getSession() returns whatever is in the cookie without verifying the JWT signature, so anything derived from it is attacker-controlled — a forged cookie produces a user id your code will happily trust. getClaims() validates the token. Every client wrapper in this repo carries the same instruction in its doc comment:
// src/lib/supabase/server.ts
// For authorization decisions use `getClaims()`/`getUser()` — never `getSession()` (unverified).
It is worth being blunt about the failure mode this prevents, because it is silent: a getSession()-based check passes its own tests, works in the browser, and fails only against someone who edits a cookie.
Authentication with React, as six Server Actions
There are no auth API routes here. src/lib/actions/auth.ts exports the whole surface — signInWithPassword, signUpWithPassword, signInWithGoogle, requestPasswordReset, updatePassword, signOut — and forms bind straight to them. Sign-in, in full:
export async function signInWithPassword(
_prev: AuthState,
formData: FormData,
): Promise<AuthState> {
const email = String(formData.get("email") ?? "").trim().toLowerCase();
const password = String(formData.get("password") ?? "");
if (!EMAIL_RE.test(email) || !password) {
return { ok: false, message: "Please enter a valid email and password." };
}
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
console.error("auth: signInWithPassword error", error);
// Always the same message — never let a caller distinguish "wrong
// password" from "email exists but unconfirmed" (account enumeration).
return { ok: false, message: "Incorrect email or password." };
}
return { ok: true, message: "Signed in — redirecting…" };
}
The comment is the part worth copying. Two failure branches, one message. A tutorial that returns error.message straight to the client turns the login form into an account-existence oracle: submit an address, read the response, learn whether that person has an account here. The same rule governs password reset, which returns success unconditionally:
// Don't reveal whether the account exists either way (generic message).
return {
ok: true,
message: "If an account exists for that email, a reset link is on its way.",
};
Signup adds two gates before it ever reaches the auth service — an explicit consent checkbox and a 10-character minimum, both checked server-side where they cannot be skipped by disabling a field:
if (!consent) return { ok: false, message: "Please accept the Terms and Privacy Policy" };
if (password.length < MIN_PASSWORD_LENGTH) { /* … */ }
Google OAuth is the same shape. signInWithGoogle is bound directly to a <form action={…}>, builds the callback URL, and redirects — the browser never holds a client id, a secret, or a token exchange.
Where the visitor lands after any of this is a separate problem with its own trap, and React protected routes covers it: the ?next= parameter is attacker-supplied, so safeNext() in src/lib/validation.ts rejects anything that is not a same-origin relative path before a redirect uses it.
The client SDK is the expensive part, so it is loaded last
This is the measurement that changed the code. @supabase/ssr plus auth-js is 68 KiB gzipped, 255 KiB parsed. Two call sites imported it at module scope — Header, which renders site-wide, and useOwnedProducts, which sits behind every templates grid — so the entire auth stack landed in the initial bundle of /, /blog, /docs and /pricing. Pages with no account UI on them at all were downloading and parsing an authentication library before they could settle.
Both call sites only touch the client inside an effect, so the import can wait for the effect too. src/lib/supabase/lazyClient.ts does two things:
export function hasAuthCookie(): boolean {
if (typeof document === "undefined") return false;
return /(?:^|;\s*)sb-.+?-auth-token/.test(document.cookie);
}
export async function loadSupabaseClient(): Promise<BrowserClient> {
const { createClient } = await import("@/lib/supabase/client");
return createClient();
}
The dynamic import takes the stack off the critical path; the cookie probe skips it altogether for visitors with no session. @supabase/ssr sets those cookies httpOnly: false by design — createBrowserClient reads them from document.cookie itself — so the probe sees exactly what the client would.
The honest caveat, which is in the file's own doc comment: this is a rendering shortcut and it can never grant anything. A false negative costs a stale "Sign in" affordance until the next document load. Every real gate stays on the server — authorizeDownload, and the row-level security policies below.
Authentication is not authorization
Knowing who someone is does not decide what they may read. Those are separate layers here, and the second one lives in Postgres rather than in React:
create policy "own profile" on public.profiles for select using ((select auth.uid()) = id);
create policy "own orders" on public.orders for select using ((select auth.uid()) = user_id);
create policy "own slots" on public.entitlement_slots for select using ((select auth.uid()) = user_id);
create policy "own downloads" on public.download_events for select using ((select auth.uid()) = user_id);
Six such policies exist across supabase/migrations/, every one of them for select, and not one write policy for any signed-in role — writes go through SECURITY DEFINER functions with a pinned search_path and EXECUTE revoked from the client roles. A React component cannot leak a row it is not allowed to read, because the query returns nothing to leak. The Auth0 alternative post covers that posture in full, and role-based access control in a React dashboard covers what happens when roles enter the picture.
Mistakes and how they show up
| Mistake | Symptom | Fix |
|---|---|---|
Session in Context, hydrated from localStorage | Signed-out flash on every load; token readable by any XSS | Keep the session in a cookie the server refreshes and verifies |
Using getSession() for an authorization decision | Passes every test; fails against an edited cookie | getClaims() or getUser() — anything that verifies the JWT |
Returning the auth provider's error.message | The login form becomes an account-existence oracle | One generic message for every failure branch; log the real cause server-side |
| A password-reset response that differs for unknown emails | Same enumeration leak by another route | Return the same "if an account exists…" message unconditionally |
| Validating password length only in the browser | Trivially bypassed by disabling the field | Check it in the Server Action, as MIN_PASSWORD_LENGTH is here |
| Importing the auth SDK at module scope in a site-wide component | 68 KiB gzipped on pages with no account UI | Dynamic import() inside the effect, plus a cookie probe to skip it |
| Guarding routes only in a client wrapper | The page renders, then redirects — data has already shipped | Guard on the server, before render |
Frequently asked questions
Do I need an auth library for React? You need an auth service — password hashing, email verification, OAuth handshakes and token rotation are not code to hand-roll. What you can skip is the client-side library. Here the service is Supabase, called from Server Actions, and the auth SDK is kept out of every page's initial bundle — fetched inside an effect, and only when a session cookie is actually present.
Where should the JWT be stored in a React app?
In a cookie the server sets and refreshes, not in localStorage. localStorage is readable by any script on the page, so one XSS is one stolen session, and the server cannot see it during the request that matters.
How do I do authentication with React without Next.js? The layering holds — session in a cookie, verification on the server, authorization in the database — but the mechanics change: you need a server that can run before render, which a client-only React SPA does not have. That is precisely why the pattern in this post is a Next.js one; with a pure SPA, the closest equivalent is verifying on every API call and treating the UI state as a hint.
Is useEffect ever the right place to check auth?
For rendering conveniences, yes — showing "Sign in" versus an avatar is exactly that, and it is what hasAuthCookie() serves here. For anything that gates access to data, no: by the time an effect runs, the component has rendered and the payload has already reached the browser. The same cookie probe is also what keeps the auth bundle off pages with no account UI, covered in Supabase Auth in Next.js 16.
Templates in this post
ASoc Guard is a cyber-security platform site built around a live-metrics hero and a demo funnel; ASoc Hearth is a smart-home marketing template with a three-step setup walkthrough; ASoc Ignite is an AI-applications studio site with a capability grid and a projects portfolio. Each ships as a static marketing front end — the shape that pairs with an auth layer like this one rather than containing it.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the redirect half of this problem, read React protected routes; for what replaces an auth vendor's permission model, read the Auth0 alternative.
