---
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