All topics
Supabase

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

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

The browser client reads session state that only exists in an actual browser — on the server it has nothing to read from.

Prefer

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

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

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();
}

getSession() trusts whatever storage says, which is exactly the kind of value that should never be trusted for an authorization decision.

Prefer

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();
}

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

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

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

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.

Download
.claude/skills/supabase-auth-patterns/SKILL.md

Drop 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