Supabase Auth in Next.js 16: Three Clients, One Rule, and 68 KiB That Shouldn't Ship
Three Supabase clients, not one — and why every authorization decision here uses getClaims(), never getSession(). Plus the auth stack that reached every page.
Supabase Auth in a Next.js App Router project needs three separate clients, not one: a browser client for Client Components, a request-scoped server client for Server Components and Server Actions, and a service-role client that bypasses row-level security and must never reach the browser. This storefront runs all three across 42 lines of proxy, 158 lines of Server Actions and one PKCE callback route.
The three clients, and why one won't do
A Supabase client is a bundle of two things: a URL/key pair and a place to keep the session. Those places are different on the browser and the server, which is the whole reason the client count is three rather than one.
| File | Key | Session store | Used from | Respects RLS |
|---|---|---|---|---|
src/lib/supabase/client.ts | anon | document.cookie | Client Components | Yes |
src/lib/supabase/server.ts | anon | next/headers cookie store | Server Components, Route Handlers, Server Actions | Yes |
src/lib/supabase/admin.ts | service role | none (stateless) | vetted server code only | No — bypasses it |
The browser one is nine lines and does nothing clever:
// src/lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
/** Browser Supabase client (anon key). Use in Client Components. */
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
}
The server one is where App Router specifics show up. cookies() is async in Next 16, and a Server Component render cannot write cookies at all — so the setter has to tolerate failing:
// src/lib/supabase/server.ts
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// Called from a Server Component render (cookies are read-only there);
// the proxy refreshes the session cookie instead.
}
},
},
},
);
}
That empty catch is not sloppiness, and it is the single most confusing part of the official setup. A Server Component render is read-only with respect to cookies; if the access token needs rotating during one, there is nowhere to put the new one. Something else has to do the rotating — which is the next section.
The third client is the one to be careful with. It uses the service-role key, so every row-level security policy is off — which makes each query's own where clause the security boundary, a discipline the service-role key post works through call site by call site:
// src/lib/supabase/admin.ts
import "server-only";
import { createClient as createSbClient } from "@supabase/supabase-js";
export function createAdminClient() {
return createSbClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
}
The import "server-only" on line one is load-bearing. It is not documentation — it makes the build fail if any Client Component ever pulls this module into its graph, which is the difference between a convention and a guarantee. In this codebase only two places import it: the LemonSqueezy webhook handler and the signed-URL issuer behind /api/download.
The rule: getClaims(), never getSession()
Session rotation lives in src/proxy.ts — Next 16's rename of middleware — and it runs on every non-static request:
// src/proxy.ts (42 lines)
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: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value),
);
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options),
);
},
},
},
);
await supabase.auth.getClaims();
return response;
}
The last line before the return is the entire point of the file. getClaims() verifies the JWT's signature and, as a side effect, refreshes the token when it is close to expiry — writing the rotated cookies onto the response, which is the write a Server Component render could not perform.
getSession() is the trap. It reads the session straight out of the cookie and hands it back without verifying anything. On the browser that is fine, because the cookie came from the same origin that wrote it. On the server it is not, because a cookie is client-supplied input: anyone can send you a sb-<ref>-auth-token cookie containing whatever user.id they like, and getSession() will cheerfully return it. Any authorization decision made from that value is an authorization decision made from an attacker-controlled string.
So the rule this codebase writes into the doc comment of server.ts is unconditional: for authorization decisions use getClaims() or getUser() — never getSession().
Both getClaims() and getUser() are safe; they differ in cost. getUser() calls the Auth server to validate. getClaims() validates the JWT locally against the project's signing key when one is available, which is why it is the right choice in a proxy that runs on essentially every request.
The matcher keeps that cost off the routes that have no session to refresh:
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.(?:svg|png|jpg|jpeg|webp|gif|ico|webmanifest|html)$).*)",
],
};
Note sitemap.xml and robots.txt in that exclusion list. They are there because a crawler fetching them has no session, and because a proxy that throws takes those routes down with it — which is exactly what happens if the two NEXT_PUBLIC_SUPABASE_* variables are missing from the deployment environment. The non-null assertions in these files mean a missing variable is not a quiet degradation; src/proxy.ts runs on every request, so it is a site-wide 500.
The auth flows: six Server Actions, one callback
All of the actual auth lives in src/lib/actions/auth.ts, 158 lines marked "use server". No API routes, no client-side supabase.auth.signIn calls. Sign-in is representative:
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…" };
}
Two habits are worth lifting out of that, because they recur in every action in the file:
One error message for every failure mode. Supabase will tell you whether an account exists, whether it is unconfirmed, or whether the password was wrong. Passing that distinction to the browser turns your login form into an account-enumeration oracle. The detail goes to console.error; the caller gets one sentence. requestPasswordReset goes further and returns ok: true even when Supabase errors:
// Don't reveal whether the account exists either way (generic message, R-6).
if (error) {
console.error("auth: resetPasswordForEmail error", error);
}
return {
ok: true,
message: "If an account exists for that email, a reset link is on its way.",
};
Every redirect target is allowlisted. The ?next= parameter that survives a sign-in round trip is attacker-supplied, and it is passed to redirect(). Left raw, it is an open redirect — a phishing primitive that borrows your domain's credibility. src/lib/validation.ts reduces it to two lines:
export function safeNext(next: string | null | undefined): string {
return next && SAFE_NEXT_RE.test(next) ? next : "/dashboard";
}
Anything that fails the pattern silently becomes /dashboard. Every entry point that touches next — signup, Google OAuth, the callback route — runs it through that function first.
Google OAuth is a Server Action bound straight to a form, and it always redirects:
export async function signInWithGoogle(formData: FormData): Promise<void> {
const next = safeNext(formData.get("next")?.toString());
const supabase = await createClient();
const { data, error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: `${SITE_URL}/auth/callback?next=${encodeURIComponent(next)}`,
},
});
if (error || !data?.url) {
console.error("auth: signInWithOAuth error", error);
redirect("/login?error=auth");
}
redirect(data.url);
}
Three separate flows converge on one callback — and this is the part most tutorials split into three routes for no reason:
// src/app/auth/callback/route.ts
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
const next = safeNext(searchParams.get("next"));
if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
redirect(`${origin}${next}`);
}
console.error("auth/callback: exchangeCodeForSession error", error);
}
redirect(`${origin}/login?error=auth`);
}
Email confirmation, Google OAuth and password recovery all arrive here with a PKCE ?code, and all need the same thing done with it. It has to be a Route Handler (or a Server Action) rather than a page, for the reason established above: only those can persist the session cookie. The reset-password page forwards its own ?code here rather than trying to exchange it during a render.
The 68 KiB nobody asked for
Here is the defect this codebase actually shipped, and the fix.
@supabase/ssr plus auth-js is roughly 68 KiB over the wire and 255 KiB parsed. Two components imported createClient at module scope: Header, which renders on every page of the site, and useOwnedProducts, which renders behind every templates grid. Module-scope imports are not lazy — so the entire auth stack landed in the initial bundle of /, /blog, /docs and /pricing, pages with no account UI on them at all, where it had to be downloaded and parsed before the page could settle.
Both call sites only ever touched the client inside an effect. So the import can wait for the effect too:
// src/lib/supabase/lazyClient.ts
export async function loadSupabaseClient(): Promise<BrowserClient> {
const { createClient } = await import("@/lib/supabase/client");
return createClient();
}
And for signed-out visitors it can be skipped entirely, by looking for the cookie the client would read anyway:
export function hasAuthCookie(): boolean {
if (typeof document === "undefined") return false;
return /(?:^|;\s*)sb-.+?-auth-token/.test(document.cookie);
}
That regex works because @supabase/ssr deliberately does not set httpOnly on its session cookies — createBrowserClient reads them from document.cookie itself — so the probe sees exactly what the client would. No cookie means no session to find, and the 68 KiB is never fetched.
The important caveat, and the reason this is safe: it is a rendering shortcut, not a gate. It can never grant anything. Every real authorization decision stays on the server — the RLS policies, and authorizeDownload behind /api/download, which re-runs per request regardless of what the browser believed. A false negative costs at most a stale "Sign in" affordance until the next document load, which is also when signing in or out lands, since both round-trip the page.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Session vanishes on navigation, returns on hard refresh | Nothing is rotating the token; a Server Component render can't write cookies | Refresh in src/proxy.ts with getClaims(), as above |
cookies() "can only be modified in a Server Action or Route Handler" | setAll ran during a Server Component render | Swallow it in a try/catch and let the proxy do the write |
| Auth works locally, every route 500s in production | NEXT_PUBLIC_SUPABASE_URL / _ANON_KEY unset in the deploy environment; the proxy runs on every request | Set both before deploying — a missing var here is site-wide, not a degraded feature |
getSession() returns a user who doesn't exist | It never verified the JWT; the cookie is client-supplied | Use getClaims() or getUser() for anything that gates access |
| Login form reveals which emails are registered | Distinct error messages per failure mode | One generic message for all of them; log the detail server-side |
?next= redirects users off-site after login | Unvalidated redirect parameter | Allowlist it — safeNext() above |
| Service-role key appears in a client bundle | A Client Component imported the admin client | import "server-only" at the top of that module turns it into a build error |
| Auth JS loads on marketing pages with no login UI | createClient imported at module scope by a site-wide component | Dynamic import() inside the effect that uses it |
Frequently asked questions
Do I really need all three clients, or can I share one? Three, and they are not interchangeable. The browser and server clients differ in where the session lives, so sharing one breaks session persistence in one environment or the other. The service-role client is a different thing entirely — it ignores RLS, so it belongs only in code paths you have specifically vetted, never behind a shared helper someone might reach for by accident.
Why is getSession() in the API at all if it's unsafe?
It is safe where it was designed to be used: in the browser, where the cookie was written by the same origin and no privilege decision is being made from it. It became a footgun when the same package started being used server-side, where cookies are untrusted input. Supabase's own docs now steer server code to getUser()/getClaims() for this reason.
Is getClaims() or getUser() better in the proxy?
getClaims(), on cost. getUser() makes a network call to the Auth server on every invocation; getClaims() validates the JWT locally against the project's signing key where it can. On a proxy matching nearly every request, that difference is the whole latency budget. Both verify — either is correct, one is cheaper.
Can I skip the proxy and just refresh the session in a layout? No, and this is the single most common way a Supabase + App Router setup ends up subtly broken. A layout is a Server Component: it can read cookies but cannot write them, so it can observe that a token expired and can do nothing about it. The rotation has to happen somewhere that owns a response — the proxy, a Route Handler, or a Server Action.
Templates in this post
ASoc Catalyst (an AI-automation agency marketing site), ASoc Chain (a DeFi-protocol landing page) and ASoc Cognition (an AI-consulting agency site) are Next.js 16 + Tailwind v4 templates that drop into a project wired the way this post describes.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the proxy file itself, see Next.js 16 renamed middleware to proxy; for what the RLS policies behind these clients actually enforce, new row violates row-level security policy.
