HomeGuidesSupabase getSession insecure warning: getUser vs getClaims

Guide

Supabase getSession insecure warning: getUser vs getClaims

Why Supabase warns that getSession() could be insecure on the server, when to use getClaims() or getUser(), and the Next.js fix for proxy, pages, and actions.

18 min read

Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.

You run your Next.js app, open a page, and the terminal fills with this:

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.

(Older versions printed "many not be authentic", a typo. Same warning.)

It is not noise. It means some server code is about to trust a user id that came straight from a cookie the browser controls. It is an easy pattern for Cursor, Lovable, Bolt, or v0 to produce, because getSession() hands you a user object right away and works perfectly in a demo. This guide explains what the warning is protecting you from, the difference between getSession(), getUser(), and getClaims(), where each one belongs, and copy-paste Next.js App Router code that fixes it.

Checked on October 20, 2026 against @supabase/supabase-js 2.118.0 (which bundles @supabase/auth-js 2.118.0), @supabase/ssr 0.12.7, the Supabase SSR, sessions, JWT, and signing keys docs, and the Next.js 16 authentication docs linked below. We ran the three methods against forged cookies locally (details below) and type-checked the code samples with Next.js 16.4.2 and TypeScript. VibeLocked is independent and not affiliated with Supabase.

The short version

  • getSession() on the server reads the cookie and believes it. It does not check the session with Supabase Auth, so session.user.id, email, and app_metadata are whatever the request says.
  • getClaims() verifies the access token. With asymmetric JWT signing keys (the default for new projects) it checks the signature locally against your project's cached public keys. On the legacy shared secret it asks the Auth server instead. Use it to protect pages, actions, routes, and the proxy.
  • getUser() always asks the Auth server. It is slower, but it returns the current user record and fails once the session has been signed out. Use it before account, email, password, and billing changes.
  • Proxy (middleware) is not your only lock. Check the user again inside each Server Component, Server Action, and Route Handler that touches private data.
  • RLS still protects queries made with the user's token. It does nothing for secret key code, direct Postgres connections, or calls to other APIs.

What the warning actually means

With @supabase/ssr, the session lives in a cookie named sb-<project-ref>-auth-token. It holds the access token (a signed JWT), the refresh token, the expiry, and a copy of the user object. On the server, createServerClient reads that cookie on every request. The library marks this storage as server-side, with a comment in its source that the cookie values "should not be trusted."

The getSession reference says it plainly: the method "loads values directly from the storage attached to the client. If that storage is based on request cookies for example, the values in it may not be authentic." In the auth-js source, getSession() only checks that the stored object has an access_token, a refresh_token, and an expires_at. If the expiry is in the future, it returns the session as stored. It never looks at the signature.

Here is the catch: a cookie is just text the browser sends. Anyone can send any cookie with a request, and so can a script. If your server does this:

const { data: { session } } = await supabase.auth.getSession();
if (!session) redirect('/login');
const userId = session.user.id; // whatever the cookie says

then userId is chosen by the caller. That is the textbook case of CWE-565: Reliance on Cookies without Validation and Integrity Checking.

The warning itself comes from a proxy around the user object. It fires the first time your code reads a property such as session.user.id on a server client, not when you call getSession(). A Supabase maintainer explained in auth-js issue #873 that they added it because many people were using getSession() on the server to "verify" a login, and with React Server Components "the user details in the session can be faked by the client."

What we verified locally

We did not want to repeat the docs without checking, so we ran the real @supabase/ssr 0.12.7 server client in Node 22 against a local stand-in for the two Auth endpoints the client calls (the JWKS key list and /auth/v1/user). The stand-in held an ES256 signing key, like a project on asymmetric keys. Then we sent it cookies:

Cookie getSession() getClaims() getUser()
Token signed with the attacker's own key, victim's id, real key id copied from the JWKS Victim's id and app_metadata.role = 'admin', plus the warning Invalid JWT signature, checked locally Rejected by the Auth endpoint
Unsigned token (alg: none), victim's id Victim's id and admin role No usable key, so it falls back to the Auth endpoint, rejected Rejected
Attacker's real token, but the user object in the cookie edited to the victim Victim's id Attacker's id (from the verified token) Attacker's id
Valid token, session signed out on the server Victim's id Still accepted AuthSessionMissingError

What this shows, and what it does not:

  • getSession() returned whatever we put in the cookie, every time, including a made-up admin role.
  • getClaims() rejected the forged signature on its own, with no request to /user. That part is pure client code: it fetched the JWKS once, cached it, and checked the signature with WebCrypto. For tokens it cannot check locally, it called the Auth endpoint, exactly as its source says.
  • The getUser() rejections came from our stand-in, which verifies tokens the way Supabase's JWT docs describe the real Auth server doing. We did not attack a real Supabase project.
  • Row 4 is the documented trade-off of getClaims(), covered below.

We also checked what a database query sends: a .from('notes').select() call through that server client carried the access token from the cookie as Authorization: Bearer, not the user object. That matters for the RLS section.

getSession vs getUser vs getClaims

Supabase's Next.js SSR guide now puts it in one line: "Never trust supabase.auth.getSession() inside server code such as Proxy. It reads the session out of the cookie without revalidating it."

getSession() getClaims() getUser()
Reads Session object from storage Access token from storage (or one you pass in) Access token, then the user record from Auth
Verifies the signature No Yes Yes, on the Auth server
Network call Only to refresh an expired token None on asymmetric keys once the JWKS is cached; a call to Auth on the legacy secret Always
Notices sign-out, ban, or deletion No Not until the token expires Yes
Returns Tokens plus an unverified user object JWT claims: sub, email, role, app_metadata, session_id, and more Full user record
Use it for Browser UI, or reading the raw token Protecting pages, actions, routes, proxy, Edge Functions Sensitive actions and fresh profile data

getClaims() in more detail

The getClaims reference says it verifies the JWT against your project's /.well-known/jwks.json key set, "which is often cached," and that Supabase prefers it over getUser(). From the source, three details are worth knowing:

  1. It depends on your signing keys. If the token's alg starts with HS (the legacy shared secret), has no key id, or WebCrypto is missing, it calls getUser() to validate the token and returns the claims only if that succeeds. Supabase's JWT signing keys guide explains how to migrate to asymmetric keys if your project is older.
  2. The key cache lasts 10 minutes in the library, on top of a 10 minute edge cache on Supabase's side, per the signing keys guide. That is why it is fast, and why a revoked signing key can still be trusted by your app for a while.
  3. It checks signature and expiry, not the session. Supabase's advanced SSR guide says "an unexpired token stays valid even when the session behind it was revoked. Call getUser() where that gap matters." The sessions guide suggests the default 1 hour expiry for most apps, and describes checking the session_id claim against auth.sessions for the rare action that needs a hard guarantee. We tested how long a signed out token keeps passing getClaims() and the Data API in Supabase signOut scopes and session revocation. Our guide to revoking a removed team member's access has that check as a tested restrictive RLS policy, plus a server-only function that deletes a user's sessions.

One more thing the reference notes: getClaims() refreshes the session first when the access token is about to expire. That is why the SSR guide calls it in the proxy, where it keeps users signed in.

getUser() in more detail

The getUser reference and the source comment say it "performs a network request to the Supabase Auth server, so the returned value is authentic and can be used to base authorization rules on." The trade-off is a round trip to your project's region on every call. Use it where freshness matters: deleting an account, changing an email or password, changing billing, or any page that must reflect a ban right away.

When getSession() is fine

In the browser, the session in local storage or cookies belongs to the person using it. If they edit it, they only fool their own screen. Reading it to show "Signed in as..." or to toggle a menu is fine, and so is listening to onAuthStateChange for UI updates. It is also fine on the server when you need the raw access token to pass to something that verifies it. The rule is about trusting the user object for a server decision. Why the browser side keeps these tokens readable by JavaScript at all, and what that means for XSS, is covered in JWT in localStorage or httpOnly cookie.

Where each check belongs in Next.js

Where the code runs What to call Why
Client Component getSession() or onAuthStateChange for display UI only; RLS and the server do the real checks
proxy.ts (middleware.ts before Next.js 16) getClaims() Refreshes the token and gives a quick redirect, but is not the only check
Server Component, page, or layout data getClaims() Decides what private data to render
Server Action getClaims(), or getUser() for sensitive changes Actions are public endpoints anyone can call
Route Handler getClaims(), or getUser() for sensitive changes Same as above
Supabase Edge Function getClaims(token) See Supabase Edge Function auth
Your own backend (Express, FastAPI, a worker) Verify the JWT against your JWKS, or call /auth/v1/user Never decode the token without verifying it

Why the proxy alone is not authorization

The Next.js authentication guide says Proxy "should not be your only line of defense in protecting your data" and calls the checks there "optimistic." The Proxy docs add that "a matcher change or a refactor that moves a Server Function to a different route can silently remove Proxy coverage," and say to verify auth inside each Server Function. The data security guide says "a page-level authentication check does not extend to the Server Actions defined within it." And the authentication guide warns that a check in a layout does not stop the pages and slots under it from rendering. Check close to the data, every time.

There is a real history here too. CVE-2025-29927 let a single header skip Next.js middleware on self-hosted apps. Apps that only checked auth in middleware were open.

Typical AI-generated mistakes

1. getSession() in middleware to gate /admin.

// Unsafe: the cookie decides who is an admin
const { data: { session } } = await supabase.auth.getSession();
if (session?.user.app_metadata?.role !== 'admin') {
  return NextResponse.redirect(new URL('/', request.url));
}

app_metadata is safe to trust only when it comes from a verified token. Read from getSession(), it is just text. In our test a forged cookie produced role: 'admin' here. The client-side auth guide covers the rest of a safe admin setup.

2. Trusting session.user.id for writes with the secret key.

// Unsafe: service role client + cookie user id
const { data: { session } } = await supabase.auth.getSession();
await supabaseAdmin
  .from('profiles')
  .update({ display_name: body.name })
  .eq('id', session!.user.id);

The secret key (or legacy service_role key) bypasses Row Level Security, as the API keys guide explains, so the forged id is the only thing deciding whose row changes. This is how a cookie edit turns into editing someone else's account. The anon vs service role guide covers where that key belongs, and the IDOR guide covers owner filters.

3. Roles in user_metadata. Switching to getClaims() fixes the signature problem, but user_metadata is still written by the user. Supabase's RLS guide says raw_user_meta_data "can be updated by the authenticated user" and "is not a good place to store authorization data." Use app_metadata, a roles table, or a custom access token hook as shown in Supabase's RBAC guide. The same goes for plan and credits columns: lock them with column grants.

4. Decoding the JWT by hand. jwt-decode, atob, or JSON.parse on the middle part of the token reads the claims without checking the signature. It is getSession() with extra steps.

5. Silencing the warning instead of fixing it. Wrapping console.warn or deleting session.user hides the message and keeps the hole.

Does RLS save you?

Partly, and it is worth being exact.

When your server code queries through the @supabase/ssr client with your publishable key, the request carries the access token from the cookie, as our check showed. Supabase's JWT docs say each Supabase product will "securely decode and verify the validity of a JWT it receives before using Postgres policies and roles to authorize access." So a forged token is rejected, and an edited user object never reaches the database at all. Policies built on (select auth.uid()) keep working. Keep them, and keep the RLS checklist handy.

RLS does nothing for:

  • Secret key or service_role code paths. RLS is bypassed, so your code is the only check.
  • Direct Postgres connections through Prisma, Drizzle, or pg with your database URL. Those usually connect as a role that owns the tables, so policies do not run.
  • Calls to other services. A route that calls OpenAI, Stripe, Resend, or your own API "for the signed-in user" uses whatever id you hand it.
  • Rendering decisions. A Server Component that shows an admin screen or a paid feature based on session.user has already leaked it, even if the queries come back empty.

The fix for Next.js App Router

These files follow the Supabase SSR guide and Vercel's with-supabase example. They type-check against Next.js 16.4.2, @supabase/ssr 0.12.7, and @supabase/supabase-js 2.118.0. Use the publishable key in NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY. The secret key goes in a server-only variable without the NEXT_PUBLIC_ prefix.

1. The server client, lib/supabase/server.ts:

import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options),
            );
          } catch {
            // Called from a Server Component. Safe to ignore when
            // proxy.ts refreshes the session on every request.
          }
        },
      },
    },
  );
}

2. The proxy, lib/supabase/proxy.ts plus proxy.ts. On Next.js 15 or older, name the file middleware.ts and the function middleware.

// lib/supabase/proxy.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet, headers) {
          cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
          supabaseResponse = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options),
          );
          Object.entries(headers ?? {}).forEach(([key, value]) =>
            supabaseResponse.headers.set(key, value),
          );
        },
      },
    },
  );

  // Verifies the token (and refreshes it when it is about to expire).
  // Do not put other code between createServerClient and this call.
  const { data } = await supabase.auth.getClaims();
  const claims = data?.claims;

  const path = request.nextUrl.pathname;
  const isPrivate = path.startsWith('/dashboard') || path.startsWith('/admin');

  if (isPrivate && !claims) {
    const url = request.nextUrl.clone();
    url.pathname = '/login';
    return NextResponse.redirect(url);
  }

  // Return this exact object so refreshed cookies reach the browser.
  return supabaseResponse;
}
// proxy.ts
import { type NextRequest } from 'next/server';
import { updateSession } from '@/lib/supabase/proxy';

export async function proxy(request: NextRequest) {
  return await updateSession(request);
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
};

The headers argument passes on the cache headers the SSR guide says to apply, so a CDN does not cache a response that sets someone's session cookie.

3. One auth helper for everything else, lib/auth.ts:

import 'server-only';
import { cache } from 'react';
import { redirect } from 'next/navigation';
import { createClient } from '@/lib/supabase/server';

// Verified identity for Server Components, Server Actions, and Route Handlers.
export const requireUser = cache(async () => {
  const supabase = await createClient();
  const { data, error } = await supabase.auth.getClaims();
  if (error || !data?.claims?.sub) redirect('/login');
  return { userId: data.claims.sub, claims: data.claims, supabase };
});

export async function requireAdmin() {
  const auth = await requireUser();
  // app_metadata can only be set by your server, never user_metadata.
  if (auth.claims.app_metadata?.role !== 'admin') redirect('/');
  return auth;
}

// For account deletion, email or password changes, and billing:
// ask the Auth server, which also knows if the session was signed out.
export async function requireFreshUser() {
  const supabase = await createClient();
  const { data, error } = await supabase.auth.getUser();
  if (error || !data.user) redirect('/login');
  return { user: data.user, supabase };
}

React's cache keeps it to one verification per request, the pattern the Next.js authentication guide uses for its verifySession() helper.

4. Use it in pages and actions:

// app/admin/page.tsx
import { requireAdmin } from '@/lib/auth';

export default async function AdminPage() {
  const { supabase } = await requireAdmin();
  const { data: orders } = await supabase
    .from('orders')
    .select('id, total_cents, status');
  return <pre>{JSON.stringify(orders, null, 2)}</pre>;
}
// app/dashboard/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { requireUser } from '@/lib/auth';

export async function deleteNote(noteId: string) {
  const { userId, supabase } = await requireUser();

  const { data, error } = await supabase
    .from('notes')
    .delete()
    .eq('id', noteId)
    .eq('user_id', userId)
    .select('id');

  if (error) throw new Error('Could not delete note');
  if (!data?.length) throw new Error('Not found');
  revalidatePath('/dashboard');
}

The .eq('user_id', userId) is redundant if RLS is right, and that is the point: two locks. More on actions in Next.js Server Actions security.

5. If a route must use the secret key, verify first and filter by the verified id:

// app/api/notes/[id]/route.ts
import { createClient } from '@/lib/supabase/server';
import { supabaseAdmin } from '@/lib/supabase/admin'; // server-only, secret key

export async function PATCH(
  request: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const supabase = await createClient();
  const { data: auth } = await supabase.auth.getClaims();
  const userId = auth?.claims?.sub;
  if (!userId) return new Response(null, { status: 401 });

  const { id } = await params;
  const body = (await request.json()) as { title?: unknown };
  if (typeof body.title !== 'string') return new Response(null, { status: 400 });

  // The admin client skips RLS, so the owner filter is the only lock.
  const { data, error } = await supabaseAdmin
    .from('notes')
    .update({ title: body.title })
    .eq('id', id)
    .eq('user_id', userId)
    .select('id, title');

  if (error) return new Response(null, { status: 500 });
  if (!data?.length) return new Response(null, { status: 404 });
  return Response.json(data[0]);
}

6. For a backend that is not Supabase, verify the token the way the JWT docs show, with jose and createRemoteJWKSet pointed at https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json. On the legacy shared secret, they recommend calling GET /auth/v1/user with the token and treating anything but a 200 as invalid.

Why the warning keeps showing after you fix it

Some people switch to getClaims() and still see the message. Common reasons, from the GitHub threads:

  • A helper or your own code serializes the whole session, which touches session.user. supabase-js issue #1709 traced repeated warnings in SvelteKit to JSON.stringify of the session.
  • A library calls getSession() under the hood, as in issue #1895 with the old auth helpers on Next.js 16. The fix in pull request #1898, merged in December 2025, stops the warning once getUser() has returned a user on that client. We confirmed the same logic in 2.118.0.
  • You are on the deprecated @supabase/auth-helpers-nextjs. Move to @supabase/ssr.

Search your server code for getSession and session.user before you assume it is a false alarm.

Checklist

  • No session.user from getSession() in proxy, middleware, Server Components, Server Actions, Route Handlers, or Edge Functions
  • Proxy calls getClaims() and returns the supabaseResponse it built
  • Every page, action, and route with private data calls a helper built on getClaims()
  • getUser() before account deletion, email or password changes, and billing
  • Roles read from verified app_metadata or a roles table, never user_metadata
  • Secret key code filters by the verified sub, never by an id from the cookie or the request body
  • No hand-decoded JWTs; other backends verify against the JWKS
  • Project moved to asymmetric JWT signing keys, so getClaims() verifies locally

Copy-paste prompt for your AI coding tool

Paste this into Cursor, Claude Code, Lovable, Bolt, or v0. Share file names and code only, never keys, tokens, or .env contents.

Audit this Next.js App Router project for unsafe Supabase auth on the server. Find every call to supabase.auth.getSession() and every read of session.user (id, email, app_metadata, user_metadata) in proxy.ts or middleware.ts, Server Components, layouts, Server Actions, Route Handlers, and lib code. Replace server-side identity checks with supabase.auth.getClaims() using the @supabase/ssr server client, through one server-only helper (requireUser) wrapped in React cache, and use getUser() before account deletion, email or password changes, and billing. Keep getClaims() in proxy for session refresh, but also check the user inside every page, action, and route that reads or writes private data. Read roles only from verified app_metadata or a roles table, never user_metadata. Wherever a client uses the Supabase secret or service_role key, make sure it filters by the verified claims.sub and never by an id from cookies or the request body. Remove any hand-decoded JWTs. List each change with the file and line.

Review the diff, then sign in as two test users and confirm each one sees only their own data. The security review prompt guide has more prompts like this.

Sources

Questions

What does 'Using the user object as returned from supabase.auth.getSession() could be insecure' mean?

Your server code read session.user from getSession(). On the server, getSession() loads the session from the request cookie and does not check it with Supabase Auth, so the user object is whatever the browser sent. Anyone can edit or forge that cookie. The warning is logged when a property of that user object is read on a server client. Replace it with getClaims(), which verifies the access token, or getUser(), which asks the Auth server.

Should I use getClaims() or getUser() on the server?

Use getClaims() for most checks: protecting pages, Server Actions, Route Handlers, and the Next.js proxy. Supabase recommends it, and with asymmetric signing keys it verifies the token locally against your project's cached public keys. Use getUser() when you need the current user record or must know the session is still alive, for example before deleting an account, changing an email or password, or touching billing. getClaims() does not notice a sign-out until the token expires.

Is getSession() ever safe to use?

Yes. In the browser it is fine for showing the signed-in state, because the user can only fool their own screen and your database still checks the token. On the server it is fine when you only need the raw access token, for example to forward it to another service that verifies it. What is not safe is trusting session.user, its id, its email, or its metadata to decide what a server request may do.

Why do I still see the warning after switching to getUser() or getClaims()?

Something else still reads the user object from getSession() on the server: a helper library, an onAuthStateChange listener, or code that serializes the whole session. Older supabase-js versions also logged it more often. A fix merged in December 2025 stops the warning once getUser() has returned a user on that client, so update @supabase/supabase-js and @supabase/ssr, then search your server code for getSession and session.user.

Does Row Level Security protect me if my server code uses getSession()?

Partly. Queries made through a Supabase client that carries the user's access token are checked by the Data API, which verifies the token before your auth.uid() policies run, so a forged cookie gets no rows. RLS does not help when the server uses the secret or service_role key, when it talks to Postgres through Prisma or Drizzle, when it calls another API such as OpenAI or Stripe, or when it only decides what page to render. Those paths need a verified user id from getClaims() or getUser().