Skip to main content
ASoc
Tutorial

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().

The ASoc Team9 min read

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.

ApproachSession stored inVerified byCost
Context + localStorageJavaScript memory, rehydrated on loadThe client, usually not at allXSS-readable; flashes signed-out on every load; server knows nothing
Client SDK + cookieCookie read by the SDK in the browserThe auth service, over the networkThe SDK ships to every page that might need it
Cookie verified server-sideCookie, refreshed by the serverThe server, per request, before renderAuth 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

MistakeSymptomFix
Session in Context, hydrated from localStorageSigned-out flash on every load; token readable by any XSSKeep the session in a cookie the server refreshes and verifies
Using getSession() for an authorization decisionPasses every test; fails against an edited cookiegetClaims() or getUser() — anything that verifies the JWT
Returning the auth provider's error.messageThe login form becomes an account-existence oracleOne generic message for every failure branch; log the real cause server-side
A password-reset response that differs for unknown emailsSame enumeration leak by another routeReturn the same "if an account exists…" message unconditionally
Validating password length only in the browserTrivially bypassed by disabling the fieldCheck it in the Server Action, as MIN_PASSWORD_LENGTH is here
Importing the auth SDK at module scope in a site-wide component68 KiB gzipped on pages with no account UIDynamic import() inside the effect, plus a cookie probe to skip it
Guarding routes only in a client wrapperThe page renders, then redirects — data has already shippedGuard 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.

Keep reading

Tutorial9 min read

Best SaaS Landing Pages: What 17 Real Templates Actually Ship

Not a gallery — a section-by-section census of 17 SaaS landing templates. Pricing, testimonials and integrations each appear in 10 of 17; the median product is 11 pages.

Read more
Tutorial9 min read

Collapsible HTML: Why This Codebase Skips the Details Element

Zero details elements across 91 components, and the CSS grid-template-rows trick FaqItem uses instead — plus when the native element is the right call.

Read more
Tutorial8 min read

What's Actually in a Data Dashboard? 59 Views, Counted

9 admin templates, 59 dashboard views, audited from the catalog — what a coded data dashboard actually contains versus a BI tool.

Read more