On this page
Authentication is where a side project meets its first real adversary. The happy path (a user types an email, gets in) is the easy 20 percent. The other 80 percent is everything that goes wrong: a bot hammering signup, a forged session cookie, a logged-in user reaching for someone else's data, an OAuth callback that lands with no code, a password reset link that was already used. This page is the auth setup that handles all of it, built on Supabase Auth in a Next.js 16 App Router app, with email and password plus Google sign-in.
The shape: validated inputs at the boundary, every mutation in a server action, every read through one Data Access Layer that verifies the session cryptographically, OAuth gated behind an env flag, and a callback route that treats every failure as expected. Sessions are verified with getUser(), never trusted from getSession().
Verify sessions, do not trust them
The first rule is the one most tutorials get wrong. supabase.auth.getSession() reads the session from the cookie without checking it; getUser() re-verifies the JWT against the auth server. On the server, always use getUser(). Wrap it in React's cache() so layout, page, and every server component share one round trip per request.
import "server-only";
import { cache } from "react";
import { createServerClient } from "@/lib/supabase/server";
// Canonical auth check. cache() dedupes per request, so calling this from a
// layout AND five components costs one DB round trip. Always getUser() (it
// cryptographically verifies), never getSession() (it just reads the cookie).
export const verifySession = cache(async () => {
const sb = await createServerClient();
const {
data: { user },
} = await sb.auth.getUser();
if (!user) return null;
const { data: profile } = await sb
.from("profiles")
.select("*")
.eq("user_id", user.id)
.maybeSingle();
return profile ? { userId: user.id, email: user.email, profile } : null;
});The rules
Whenyou read the current user anywhere on the server
Docall auth.getUser(), never getSession(). Funnel it through one cached DAL function so a forged cookie can never stand in for a verified user.
const { data: { user } } = await sb.auth.getUser(); // verified
if (!user) redirect("/login");Whenyou accept signup, login, reset, or any auth form
Dovalidate with a zod schema in a server action before calling Supabase. Enforce a real password policy and require an explicit accept-terms checkbox.
"use server";
const signUpSchema = z.object({
email: z.string().email(),
password: z.string().min(10)
.regex(/[A-Z]/).regex(/[a-z]/).regex(/[0-9]/),
acceptTerms: z.literal(true, { message: "Accept the Terms and Privacy Policy." }),
company: z.string().max(0).optional(), // honeypot: bots fill it, humans can't see it
});Whenan auth endpoint can be hit repeatedly (signup, login, password reset)
Dorate-limit by IP and by hashed email before touching Supabase. Hash the email so the limiter key never stores a raw address.
const ip = await clientIp();
const a = await rateLimit(`signup:ip:${ip}`, 5, 60_000);
const b = await rateLimit(`signup:email:${hashEmail(email)}`, 3, 60_000);
if (!a.ok || !b.ok) return { ok: false, error: "Too many attempts. Try later." };Whenyou add Google or any social sign-in
Dogate the button behind a public env flag so it only renders where the provider is configured, force account selection, and request only the scopes you use.
"use client";
const enabled = process.env.NEXT_PUBLIC_GOOGLE_AUTH === "true";
// ...
await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: `${origin}/auth/callback?next=${encodeURIComponent(safeNext)}`,
queryParams: { prompt: "select_account" }, // no access_type:offline, no refresh token
},
});Whenany auth flow redirects to a next or returnTo value from the URL
Doallowlist it to a path that starts with /. A user-controlled redirect target is an open-redirect, used for phishing and to bounce sessions off-site.
const safeNext = next.startsWith("/") ? next : "/dashboard";Whenyou write the OAuth / email-confirmation callback route
Dohandle every branch: provider error, code present (exchange it), and no code at all. Map Supabase error codes to stable tokens the login page can explain, and never let a best-effort side effect block the redirect.
if (oauthError) return redirect("/login?error=oauth_denied");
if (code) {
const { error } = await sb.auth.exchangeCodeForSession(code);
if (error) return redirect(`/login?error=${exchangeErrorToken(error)}`);
try { await bestEffortSideEffects(userId); } catch (e) { log.error(e); } // never throws
return redirect(safeNext);
}
return redirect("/login?error=no_code"); // wrong redirect URL configuredWhenyou protect a page, action, or API behind login
Dore-check the session at the data layer with requireUser(), not just in proxy.ts. The proxy is a coarse gate for UI; server actions are independent POST endpoints that must verify the caller themselves.
export async function requireUser() {
const session = await verifySession();
if (!session) redirect("/login");
return session;
}Google sign-in: the parts that trip people up
Three things break Google OAuth in practice, none of them in your code:
- The redirect URL must match exactly. The
redirectToyou pass, the Supabase "Redirect URLs" allowlist, and the Google Cloud "Authorized redirect URIs" all have to agree. A callback that arrives with neither acodenor anerroralmost always means the provider fell back to the Supabase Site URL because your URL was not on the list. - Scopes and tokens are a privacy decision, not just a technical one. Request only
openid,email,profile. Skipaccess_type: "offline"unless you genuinely need a Google refresh token; your own session is the short-lived Supabase JWT, so the one-time ID token at sign-in is enough. Whatever you request, you must disclose in your privacy policy. - Google verification requires the legal pages. To move the OAuth consent screen out of "testing" and show your real app name, Google requires a published privacy policy that discloses exactly which Google user data you use, under the Limited Use terms. That is not optional. See Legal Pages.