Short answer: on the server, never trust supabase.auth.getSession() for authorization. It reads the session from the cookie without checking it with Supabase, so a user can edit the cookie and pretend to be someone else. Use supabase.auth.getUser(), which validates the access token with the Supabase Auth server on every call, or supabase.auth.getClaims(), which verifies the JWT signature (locally when your project uses asymmetric signing keys). In the browser, getSession() is fine for showing UI.
If you've built a Next.js app with Supabase through Lovable, Bolt, v0, Cursor or Replit, you may have seen this warning in your server logs:
Using the user object as returned from supabase.auth.getSession() or from some
supabase.auth.onAuthStateChange() events could be insecure! This value comes
directly from the storage medium (usually cookies on the server) and may not be
authentic. Use supabase.auth.getUser() instead which authenticates the data by
contacting the Supabase Auth server.
It's worth taking seriously. Here's what it means and how to fix it.
Why getSession() is unsafe on the server
With @supabase/ssr, the user's session (access token, refresh token and user object) lives in cookies. On the server, getSession() decodes that cookie and returns what's inside. It doesn't verify that Supabase issued it.
The cookie is controlled by the browser, which means it's controlled by the user. Someone can:
- Change the
user.idoruser.emailinside the stored session. - Paste in an expired or made-up token.
- Copy a session structure from another app.
If your server code does this, it trusts whatever the cookie says:
// vulnerable: trusts an unverified cookie
const { data: { session } } = await supabase.auth.getSession();
if (!session) redirect("/login");
const isAdmin = session.user.email === "[email protected]";
const { data } = await adminClient.from("invoices").select("*").eq("user_id", session.user.id);
There's an important nuance. If the request then queries Supabase with the user's client, RLS still checks the real JWT signature in the database, so a forged cookie usually just gets an error. The damage happens when the server uses the session's user id or email for its own decisions: admin checks, calls to Stripe or OpenAI, queries made with the service_role key, or data fetched from a different database. That's exactly the pattern AI-generated code tends to produce.
The three methods compared
| Method | What it checks | Network call | Use it for |
|---|---|---|---|
getSession() |
Nothing, reads storage | No (unless it refreshes) | Client side UI, reading the access token to send to your API |
getUser() |
Asks Supabase Auth if the token is valid and the user still exists | Yes, every call | Server side authorization, especially sensitive actions |
getClaims() |
Verifies the JWT signature and expiry | No with asymmetric keys (uses cached JWKS); falls back to a server call with the legacy shared secret | Fast server side checks in middleware and server components |
getUser() is the most thorough: it also catches users who were deleted or banned after their token was issued, and sessions that were signed out. getClaims() is faster but only proves the token is genuine and unexpired, so a token stays valid until it expires (one hour by default) even if the user was removed.
The safe pattern in Next.js App Router
Middleware
The Supabase SSR middleware refreshes the session and should protect routes using a verified call:
// middleware.ts (simplified)
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookies) => {
cookies.forEach(({ name, value }) => request.cookies.set(name, value));
response = NextResponse.next({ request });
cookies.forEach(({ name, value, options }) => response.cookies.set(name, value, options));
},
},
},
);
// verified, unlike getSession()
const { data } = await supabase.auth.getClaims();
if (!data?.claims && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return response;
}
Don't treat middleware as your only check, though. Middleware can be skipped by routing quirks and bugs (see the Next.js middleware bypass CVE-2025-29927 guide), so check again where the data is read or changed.
Route handlers and server actions
// app/api/invoices/route.ts
import { createClient } from "@/lib/supabase/server";
export async function GET() {
const supabase = await createClient();
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { data } = await supabase
.from("invoices")
.select("*")
.eq("user_id", user.id);
return Response.json(data ?? []);
}
Server actions are public POST endpoints, so they need the same check at the top of every action. See Next.js server actions security for the details and how to secure Next.js API routes for ownership checks.
Admin checks
Don't decide who is an admin from the email in a cookie, or from user_metadata, which users can edit themselves with updateUser(). Store roles in a table the user can't write to, or in app_metadata, which only the service role can change:
const { data: { user } } = await supabase.auth.getUser();
if (user?.app_metadata?.role !== "admin") {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
When getSession() is fine
- In client components for deciding what to render. Anything the client decides can be faked anyway, so the real check must be on the server and in RLS.
- To read the access token you then send to your own API in an
Authorizationheader, as long as that API verifies it withgetUser(token)orgetClaims(). - After you've already verified the user with
getUser()in the same request, if you need the access token itself.
Supabase Edge Functions
Edge functions receive the user's JWT in the Authorization header. Verify it before using it:
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
{ global: { headers: { Authorization: req.headers.get("Authorization") ?? "" } } },
);
const { data: { user } } = await supabase.auth.getUser();
if (!user) return new Response("Unauthorized", { status: 401 });
Decoding the JWT yourself with a base64 decode, without checking the signature, has the same problem as getSession().
Quick audit for your codebase
Search your project for these and review each match:
grep -rn "getSession()" app/ lib/ src/ middleware.ts 2>/dev/null
grep -rn "user_metadata" app/ lib/ src/ 2>/dev/null
grep -rn "jwt-decode\|jwtDecode\|atob(" app/ lib/ src/ 2>/dev/null
Any getSession() in a file under app/api, a server action, a server component or middleware that is used to decide access should become getUser() or getClaims(). Any user_metadata check that gates permissions should move to app_metadata or a roles table.
Then check the data layer. Even with perfect server checks, tables without row level security can be read directly with the anon key. Work through the Supabase RLS checklist, and if you're seeing write errors after enabling it, see how to fix "new row violates row-level security policy".
Get an automated check
PreflightPR reviews AI-built apps for unverified server auth, API routes without auth checks, missing Supabase RLS, leaked keys and other launch blockers. Run a free scan on a public repo for a launch score and a prioritized fix list, or install the PreflightPR GitHub App to check every pull request before it merges. For everything else to check before you ship, see the pre-launch security checklist for AI-built apps.