6 min read
Auth Patterns
Server and browser clients aren't interchangeable, getUser() verifies where getSession() merely reads, and middleware is where a stale session gets refreshed.
Principles
Server and client need different Supabase clients
A browser client persists the session in cookies/local storage for the user; a server client (Server Component, Route Handler, middleware) reads that same session from request cookies without ever holding a service-role key. Mixing them up either loses the session or leaks elevated privileges.
Middleware is where a stale session gets refreshed
Without a middleware step that calls `getUser()` on every request, an access token can expire mid-session and every server-rendered page silently treats the user as logged out.
JWT claims are the fast path, a database lookup is the source of truth
Custom claims (role, plan) embedded in the JWT avoid a query per request, but they're only as fresh as the last token refresh. Anything that must be instantly consistent still needs a table lookup.
`getUser()` verifies, `getSession()` merely reads
`getSession()` returns whatever's in local storage without contacting Supabase, which a client could have tampered with. Server-side authorization must call `getUser()`, which validates the JWT against Supabase's auth server.
Email confirmation and password reset flows need real redirect URL allow-listing
Supabase requires each redirect URL to be explicitly allow-listed in project settings. Forgetting to add a new environment's URL there manifests as 'confirmation links don't work' in staging, discovered right before a demo.
Patterns
What goes wrong, what to do instead, and why the difference matters.
Using the browser client on the server
Avoid
// A Server Component importing the browser client — it has no
// access to the request's cookies, so it never sees the user
// as logged in even though they are.
import { createBrowserClient } from "@supabase/ssr";
const supabase = createBrowserClient(url, anonKey);
const { data: { user } } = await supabase.auth.getUser();The browser client reads session state that only exists in an actual browser — on the server it has nothing to read from.
Prefer
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
const cookieStore = await cookies();
const supabase = createServerClient(url, anonKey, {
cookies: { getAll: () => cookieStore.getAll() },
});
const { data: { user } } = await supabase.auth.getUser();The server client reads the session from the actual request cookies, correctly recognizing the already-logged-in user.
Trusting getSession() for an authorization decision
Avoid
const { data: { session } } = await supabase.auth.getSession();
if (session) {
// getSession() reads local/cookie storage without verifying
// the JWT against Supabase — a tampered or forged value passes.
return renderAdminPanel();
}getSession() trusts whatever storage says, which is exactly the kind of value that should never be trusted for an authorization decision.
Prefer
const { data: { user }, error } = await supabase.auth.getUser();
if (user && !error) {
// getUser() round-trips to Supabase's auth server and verifies
// the token is genuinely valid.
return renderAdminPanel();
}The authorization decision is based on a verified identity, not on trusting whatever the client happened to send.
No middleware refresh, sessions silently expiring
Avoid
// No middleware.ts touching Supabase auth at all — access
// tokens expire and nothing ever refreshes them server-side.Users get silently logged out mid-session on the server, even though their refresh token is still valid — the page just never asked for a new access token.
Prefer
// middleware.ts
export async function middleware(request: NextRequest) {
const { supabase, response } = createMiddlewareClient(request);
await supabase.auth.getUser(); // refreshes the session if needed
return response;
}Every request gets a chance to refresh an expiring session before it's used, so the user stays logged in as long as their refresh token allows.
Review checklist
What I look for when reviewing a pull request that touches this area.
- Server-side code (Server Components, Route Handlers, middleware) uses a server Supabase client built from request cookies, never the browser client.
- Authorization decisions call `getUser()`, never rely on `getSession()` alone.
- Middleware refreshes the session on every request so expiring access tokens don't silently log users out.
- The `service_role` key exists only in server-only environment variables, never in anything shipped to the client.
- Custom JWT claims used for fast-path checks have a documented staleness window, and anything requiring instant consistency falls back to a table lookup.
- Every redirect URL used by email confirmation/password reset is allow-listed per environment, including staging and preview deployments.
Take the skill
Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.
.claude/skills/supabase-auth-patterns/SKILL.mdDrop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.
---
name: supabase-auth-patterns
description: >-
Supabase authentication: server vs browser client usage, session refresh
in middleware, getUser() vs getSession() for authorization, JWT claim
staleness, and redirect URL allow-listing. Use when adding or reviewing
auth flows.
argument-hint: "[auth flow]"
license: MIT
metadata:
author: John Felix Lim
version: "1.0.0"
---
# Auth Patterns
Apply when adding a Supabase auth flow or reviewing server-side authorization. Any place a decision is made based on 'is this user logged in,' confirm it calls getUser(), not getSession().
## Rules
1. **Server and client need different Supabase clients** — A browser client persists the session in cookies/local storage for the user; a server client (Server Component, Route Handler, middleware) reads that same session from request cookies without ever holding a service-role key. Mixing them up either loses the session or leaks elevated privileges.
2. **Middleware is where a stale session gets refreshed** — Without a middleware step that calls `getUser()` on every request, an access token can expire mid-session and every server-rendered page silently treats the user as logged out.
3. **JWT claims are the fast path, a database lookup is the source of truth** — Custom claims (role, plan) embedded in the JWT avoid a query per request, but they're only as fresh as the last token refresh. Anything that must be instantly consistent still needs a table lookup.
4. **`getUser()` verifies, `getSession()` merely reads** — `getSession()` returns whatever's in local storage without contacting Supabase, which a client could have tampered with. Server-side authorization must call `getUser()`, which validates the JWT against Supabase's auth server.
5. **Email confirmation and password reset flows need real redirect URL allow-listing** — Supabase requires each redirect URL to be explicitly allow-listed in project settings. Forgetting to add a new environment's URL there manifests as 'confirmation links don't work' in staging, discovered right before a demo.
## Patterns
### Using the browser client on the server
**Avoid** — The browser client reads session state that only exists in an actual browser — on the server it has nothing to read from.
```ts
// A Server Component importing the browser client — it has no
// access to the request's cookies, so it never sees the user
// as logged in even though they are.
import { createBrowserClient } from "@supabase/ssr";
const supabase = createBrowserClient(url, anonKey);
const { data: { user } } = await supabase.auth.getUser();
```
**Prefer** — The server client reads the session from the actual request cookies, correctly recognizing the already-logged-in user.
```ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
const cookieStore = await cookies();
const supabase = createServerClient(url, anonKey, {
cookies: { getAll: () => cookieStore.getAll() },
});
const { data: { user } } = await supabase.auth.getUser();
```
### Trusting getSession() for an authorization decision
**Avoid** — getSession() trusts whatever storage says, which is exactly the kind of value that should never be trusted for an authorization decision.
```ts
const { data: { session } } = await supabase.auth.getSession();
if (session) {
// getSession() reads local/cookie storage without verifying
// the JWT against Supabase — a tampered or forged value passes.
return renderAdminPanel();
}
```
**Prefer** — The authorization decision is based on a verified identity, not on trusting whatever the client happened to send.
```ts
const { data: { user }, error } = await supabase.auth.getUser();
if (user && !error) {
// getUser() round-trips to Supabase's auth server and verifies
// the token is genuinely valid.
return renderAdminPanel();
}
```
### No middleware refresh, sessions silently expiring
**Avoid** — Users get silently logged out mid-session on the server, even though their refresh token is still valid — the page just never asked for a new access token.
```ts
// No middleware.ts touching Supabase auth at all — access
// tokens expire and nothing ever refreshes them server-side.
```
**Prefer** — Every request gets a chance to refresh an expiring session before it's used, so the user stays logged in as long as their refresh token allows.
```ts
// middleware.ts
export async function middleware(request: NextRequest) {
const { supabase, response } = createMiddlewareClient(request);
await supabase.auth.getUser(); // refreshes the session if needed
return response;
}
```
## Review checklist
- [ ] Server-side code (Server Components, Route Handlers, middleware) uses a server Supabase client built from request cookies, never the browser client.
- [ ] Authorization decisions call `getUser()`, never rely on `getSession()` alone.
- [ ] Middleware refreshes the session on every request so expiring access tokens don't silently log users out.
- [ ] The `service_role` key exists only in server-only environment variables, never in anything shipped to the client.
- [ ] Custom JWT claims used for fast-path checks have a documented staleness window, and anything requiring instant consistency falls back to a table lookup.
- [ ] Every redirect URL used by email confirmation/password reset is allow-listed per environment, including staging and preview deployments.
---
From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim