← All posts
·7 min read·GuardLayer

NextAuth + Supabase RLS Without a Shared Secret

NextAuthSupabaseRLSPostgresAuthentication

TL;DR: NextAuth (Auth.js) is not one of Supabase's supported third-party auth providers, so PostgREST won't automatically trust its tokens. The popular fix — signing your own JWT with the Supabase JWT secret in the session callback — hands a forge-anything symmetric key to your app code, and it's the pattern Supabase is actively retiring. The secure fix: validate the NextAuth session server-side, connect to Postgres as a restricted NOBYPASSRLS role, and set request.jwt.claims per transaction so RLS sees the real user. Full SQL and TypeScript below.

Why doesn't Supabase RLS just work with NextAuth?

Because Supabase only natively trusts JWTs from five third-party providers: Clerk, Firebase Auth, Auth0, AWS Cognito, and WorkOS. For those, PostgREST verifies the provider's token via JWKS/issuer trust and maps it to the authenticated role for you. NextAuth is not on that list.

That means there is no automatic bridge from an Auth.js session into Postgres. Your database has no idea who the request belongs to unless you tell it. So auth.uid() returns null, every policy keyed on the current user fails closed, and you get the classic "empty result set even though rows exist" symptom. If you're seeing that generally, the auth.uid() returns null under RLS breakdown covers the other causes too.

The dangerous shortcut: signing your own Supabase JWT

The community @auth/supabase-adapter documents an RLS approach that looks convenient. In the NextAuth session callback, you sign a JWT yourself with jsonwebtoken using Supabase's JWT secret:

// The legacy pattern — DO NOT ship this
import jwt from "jsonwebtoken";

async session({ session, user }) {
  const payload = {
    aud: "authenticated",
    exp: Math.floor(Date.now() / 1000) + 60 * 60,
    sub: user.id,
    email: user.email,
    role: "authenticated",
  };
  session.supabaseAccessToken = jwt.sign(payload, process.env.SUPABASE_JWT_SECRET);
  return session;
}

You then pass supabaseAccessToken as a Bearer token to supabase-js, PostgREST accepts it, and RLS sees the claims. It works — which is exactly why it's dangerous.

Here's the problem. This is the symmetric, shared-secret model: the same secret both signs and verifies. The moment your Next.js process holds SUPABASE_JWT_SECRET, that process can mint a valid token for any user id or any role — including elevated ones. One leaked env var, one SSRF, one compromised dependency, and an attacker forges tokens at will. It's a skeleton key sitting in application memory.

It's also a dead end. Supabase has moved to asymmetric JWT signing keys: the private key is non-extractable, held only by Supabase Auth, and verification uses the public key via JWKS. The shared-secret model is being retired across the platform. The @auth/supabase-adapter docs themselves note it is community-maintained and "not officially maintained or supported by Supabase," persisting sessions to a separate next_auth schema. Treat its supabaseAccessToken recipe as legacy surface, not a recommendation.

What is the secure NextAuth + Supabase RLS pattern?

Don't give a signing key to app code at all. Instead: let NextAuth validate the session on the server, then talk to Postgres directly as a restricted role and inject the user's identity per transaction. This is the same secure shape as the Better Auth + Supabase RLS integration — near-identical, since both bridge a non-Supabase session into RLS the same way. If you're choosing between stacks, that post is worth reading side by side with this one.

Step 1 — a restricted Postgres role that cannot bypass RLS

Create a login role that is not a superuser and not a table owner (both silently bypass RLS regardless of flags), and mark it NOBYPASSRLS. This is the same restricted-role discipline described in why your ORM bypasses Supabase RLS — connecting with the wrong role is the single most common way policies get silently skipped.

-- restricted app role: cannot bypass RLS, not an owner
create role app_authenticated with login password '<strong-password>' nobypassrls;
grant authenticated to app_authenticated;   -- lets it SET ROLE authenticated

Verify enforcement before you trust it: connect as app_authenticated and run a real query against a protected table. Just as important, make sure this role does not own those tables — an owner bypasses RLS regardless of the NOBYPASSRLS flag unless you also ALTER TABLE ... FORCE ROW LEVEL SECURITY.

Step 2 — set the request context per transaction

For each request, open a transaction, inject the NextAuth user id as the sub claim with role: authenticated, switch into the authenticated role, run your queries, and commit. The true third argument to set_config makes the setting local to the transaction, so it can't leak across a pooled connection into the next user's request.

begin;
  select set_config(
    'request.jwt.claims',
    json_build_object('sub', $1::text, 'role', 'authenticated')::text,
    true                       -- local to this transaction
  );
  set local role authenticated;

  -- your queries here; RLS is now enforced as this user

commit;

In TypeScript with pg, wrap it so every query goes through the same guarded path. Note the claims are passed as a bound parameter and JSON.stringify'd — never string-concatenated into SQL:

import { Pool, type PoolClient } from "pg";
const pool = new Pool({ connectionString: process.env.APP_DB_URL }); // app_authenticated

export async function withUser<T>(userId: string, fn: (c: PoolClient) => Promise<T>) {
  const client = await pool.connect();
  try {
    await client.query("begin");
    await client.query(
      "select set_config('request.jwt.claims', $1, true)",
      [JSON.stringify({ sub: userId, role: "authenticated" })]
    );
    await client.query("set local role authenticated");
    const result = await fn(client);
    await client.query("commit");
    return result;
  } catch (e) {
    await client.query("rollback");
    throw e;
  } finally {
    client.release();
  }
}

The critical rule: userId comes from the server-validated NextAuth session (auth() / getServerSession), never from client input.

Step 3 — write policies against the JWT sub, not auth.uid()

auth.uid() casts sub to uuid. NextAuth ids are frequently non-UUID text (cuid, provider id), which makes auth.uid() error or silently mismatch. Compare auth.jwt() ->> 'sub' (text) against a text owner column instead:

alter table public.documents enable row level security;

create policy "owner can read"
on public.documents
for select
to authenticated
using ( (auth.jwt() ->> 'sub') = user_id );   -- user_id is TEXT

Write one policy per action. insert needs with check; update typically needs both using and with check.

FAQ

Is NextAuth a supported Supabase third-party auth provider? No. Only Clerk, Firebase, Auth0, Cognito, and WorkOS get native JWT trust. For those, see the Clerk + Supabase RLS setup; for NextAuth you bridge identity manually as shown above.

Do I still need the Supabase JWT secret? No — that's the whole point. The secure pattern never signs a token, so the forge-anything secret never touches your app. You authenticate to Postgres with the restricted role's password and set claims via set_config.

Why not just use the service role from my server? The service_role bypasses RLS entirely, so your policies do nothing. Use a NOBYPASSRLS, non-owner login role and SET LOCAL ROLE authenticated.

Why auth.jwt() ->> 'sub' instead of auth.uid()? auth.uid() casts to uuid. If your NextAuth ids aren't UUIDs, that cast breaks. auth.jwt() ->> 'sub' compares as text.

Where GuardLayer fits (and where it can't)

GuardLayer is a static scanner. It reads your repository — .sql migrations, policy definitions, and source — so it can catch things like tables with RLS never enabled, policies missing a with check clause, or a hardcoded Supabase secret checked into the tree.

Be clear about the limits: this NextAuth pattern is a runtime integration. GuardLayer cannot see which Postgres role your pooled connection actually authenticates as, whether SET LOCAL ROLE runs on every request, or whether set_config was called with is_local = true. A static scan can't confirm your session callback stopped signing tokens either — that lives in runtime config and process memory. So use GuardLayer to catch the structural mistakes in your policies and to flag committed secrets, and use the test-as-the-restricted-role step above to prove the runtime half is enforced. The two cover different halves of the same problem.

Catch this before it ships — free

GuardLayer scans every push for this and 23 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading