All writing
August 24, 20264 min read

Next.js and Supabase Authentication in Production

Auth that works locally and breaks in production is usually not the library. It is the boundary between where the session lives and where the code runs.

Every Next.js project I take on eventually arrives at the same afternoon: authentication works locally, works in preview, and then does something inexplicable in production. Usually it is not the auth library. It is the boundary between where the session lives and where the code runs.

I have shipped eight products on Next.js with Supabase behind them. What follows is the set of things I now do by default, and the reasons each one exists.

The session lives in a cookie, and cookies have rules

Supabase issues a JWT. In a browser-only app it sits in local storage and nothing else needs to know. The moment you introduce Server Components, that stops working: the server renders before any client code runs, so it needs the session at request time, which means a cookie.

This is the source of most of the confusion. Middleware refreshes the cookie, Server Components read it, Client Components get it hydrated. Three places, one token, and if any of them disagree you get a user who is logged in according to the browser and anonymous according to the server.

The rule I follow: the server is the source of truth, always. Client state is a convenience for rendering, never for deciding.

Never trust getSession on the server

This one costs people real security and it looks harmless.

getSession reads the cookie and decodes it. It does not verify it against the auth server. On the client that is fine, because the client is already untrusted and the data is the user's own. On the server it means a decoded token is being treated as a verified one.

Use getUser on the server. It costs a round trip. That round trip is the verification. Any code path that decides what a person is allowed to see should sit behind it.

Row level security is the authorization layer, not a backup for it

The temptation with Supabase is to check permissions in your route handler, because that is where you can see the logic. It works, right up until a second code path reaches the same table and forgets.

Write the policy in the database. Then the check is attached to the data rather than to the route, and every path that touches the table inherits it: your API, your Server Component, a background job, a future endpoint written by somebody who never read your handler.

The practical test I use: if I deleted every route file, would the data still be protected? If the answer is no, the policy is in the wrong place.

Middleware is for refreshing, not for guarding

Middleware runs on every matched request, so it is the natural place to keep the session cookie alive. It is a poor place to enforce access.

Two reasons. It runs on the edge, without your database, so it cannot ask the question that actually matters, which is what this specific user is allowed to do. And a redirect from middleware is a routing decision, not a security boundary: anything that reaches your data through another path is unaffected by it.

I use middleware to refresh the token and to bounce obviously logged out visitors away from the dashboard, because that is a user experience improvement. The real check happens in the query.

Caching will happily serve one user's page to another

This is the failure that actually frightens me, because it is silent.

Next.js caches aggressively by default, and a page rendered for a signed in user is, as far as the cache is concerned, just a page. Read a cookie in a Server Component and Next opts that route out of static rendering, which is the behaviour you want. Fetch user data through a path that does not touch cookies and you can end up with a cached response crossing between accounts.

Any route that renders per-user data should be explicit about it rather than relying on inference. And it is worth actually testing: log in as two accounts in two browsers and load the same route. It takes two minutes and it is the only way to be sure.

What I would tell someone starting today

Put the authorization in the database, verify on the server, treat the client as display only, and be explicit about which routes are personal.

None of that is exotic. It is the same discipline that has always applied to auth, made slightly harder to see because the framework now runs your code in three places and the boundaries between them are not visible in the file you are editing.

The version of this that goes wrong is not usually a wrong library or a missing feature. It is a check that lives one layer too high.

Frequently asked questions

Should I use getSession or getUser on the server?

getUser. getSession decodes the cookie without verifying it against the auth server, so on the server it treats a decoded token as a verified one. getUser costs a round trip and that round trip is the verification.

Is middleware enough to protect a route?

No. Middleware runs on the edge without your database, so it cannot ask what a specific user is allowed to do and a redirect from it is a routing decision rather than a security boundary. Use it to refresh the session and put the real check in the query.

Where should authorization live with Supabase?

In the database as row level security. A check in a route handler protects that route. A policy on the table protects every path that reaches the data, including ones written later. The test: if you deleted every route file, would the data still be protected?