better-auth + Supabase RLS: the secure way
TL;DR: better-auth is not one of Supabase's supported third-party auth providers (only Clerk, Auth0, Firebase, Cognito, and WorkOS are), so you can't register it and have Supabase trust its tokens. Most guides work around this by signing better-auth's tokens with your Supabase JWT secret — don't. That's the deprecated symmetric-key pattern, and it hands your project's signing key to app code. The secure way: better-auth already talks to your Postgres directly, so enforce RLS the same way an ORM does — connect as a restricted role and set the request context per transaction with set_config('request.jwt.claims', …).
Can I use better-auth as a Supabase auth provider?
No. Supabase's native third-party auth has first-class support for exactly five providers — Clerk, Auth0, Firebase Auth, AWS Cognito, and WorkOS — and there is no option to register an arbitrary self-hosted issuer. better-auth's JWT plugin can expose a JWKS endpoint, but Supabase won't let you point a third-party-auth integration at it. So the clean "register the provider, send its token, done" flow that works for Clerk, Auth0, and Firebase simply isn't available here.
That leaves two real options, and one of them is a trap.
Why not just share the Supabase JWT secret?
Because it's the wrong pattern, and increasingly a broken one. The common better-auth + Supabase guide tells you to grab your project's JWT secret from the dashboard and sign better-auth's tokens with it, so PostgREST accepts them. Two problems:
- It hands a forge-anything key to your app. Whoever holds the JWT secret can mint a valid token for any user,
service_roleincluded. It's the single most sensitive secret in your project, and this pattern spreads it into your auth code. - It's the legacy symmetric approach. Supabase has moved to asymmetric JWT signing keys (a private key that never leaves Supabase, public keys published via JWKS). The shared-secret HS256 flow is on the way out — build on it and you're building on a deprecating foundation.
This is the same class of mistake as the old Clerk "paste your Supabase JWT secret" template that Supabase replaced with native integration. For a provider Supabase does support, you'd never share the secret; better-auth shouldn't be the exception.
The secure pattern: a restricted role + per-request claims
Here's the key insight: with better-auth you're already validating the session server-side and you already hold a trusted Postgres connection (better-auth stores its own tables in your database). So you don't need a Supabase-signed JWT at all — you enforce RLS the same way a Prisma or Drizzle app enforces it: connect as a restricted role (never postgres or service_role, which bypass RLS) and set the request context inside a transaction.
import { Pool } from 'pg';
// APP_DATABASE_URL points at a dedicated NOBYPASSRLS, non-owner role —
// NOT the postgres/service_role connection, which skip RLS entirely.
const pool = new Pool({ connectionString: process.env.APP_DATABASE_URL });
export async function withRls<T>(
session: { user: { id: string } },
fn: (client: import('pg').PoolClient) => Promise<T>,
): Promise<T> {
const client = await pool.connect();
try {
await client.query('begin');
await client.query('set local role authenticated');
// auth.jwt() reads request.jwt.claims — set it to the better-auth user.
await client.query(
"select set_config('request.jwt.claims', $1, true)",
[JSON.stringify({ sub: session.user.id, role: 'authenticated' })],
);
const result = await fn(client);
await client.query('commit');
return result;
} catch (err) {
await client.query('rollback');
throw err;
} finally {
client.release();
}
}
Everything inside fn runs as the authenticated role with auth.jwt() ->> 'sub' resolving to the better-auth user's ID — so your existing RLS policies enforce, no Supabase token required. Because it's all set local inside begin … commit, the role and claims reset when the transaction ends and can't leak onto the next pooled connection.
The restricted-role setup (a login role that owns nothing and has NOBYPASSRLS, granted the authenticated role) is exactly the one from the ORM-bypasses-RLS guide — reuse it here.
The RLS policy pattern
Store the owner column as text (better-auth user IDs are strings, not UUIDs, so never use auth.uid() here) and compare it to the claim you set:
create table projects (
id bigint generated always as identity primary key,
name text not null,
user_id text not null -- better-auth user id, set in server code
);
alter table projects enable row level security;
create policy "Users manage their own projects"
on public.projects
for all
to authenticated
using ( (select auth.jwt() ->> 'sub') = user_id )
with check ( (select auth.jwt() ->> 'sub') = user_id );
The (select auth.jwt() ...) wrapping evaluates the claim once per statement instead of per row — the same RLS performance win noted for auth.uid(). If you're still shaping your policies, the complete guide to Supabase RLS covers the fundamentals.
What about supabase-js / PostgREST?
If you specifically want to keep querying through supabase-js (the PostgREST Data API) rather than a direct connection, be honest about the trade-off: PostgREST only accepts tokens Supabase can verify, so your options are the insecure shared-secret hack or a server-side token-exchange service that mints short-lived tokens — real work, and easy to get wrong. For most better-auth apps, the direct-Postgres route above is simpler and safer. Reserve service_role (which bypasses RLS) for genuinely trusted server jobs, never as a shortcut behind a user request.
Where GuardLayer fits (and where it doesn't)
GuardLayer is a static scanner. It can't see your better-auth session handling or whether you set the request claims correctly at runtime — so it won't tell you a query ran as the wrong user. That's on the pattern above.
What it does catch is the tempting shortcut: disabling RLS, or a USING (true) / no-user-scope policy to "make better-auth work." GuardLayer flags RLS turned off on a table, over-permissive policies, and predicates that don't reference the user — and it flags a hardcoded connection string or JWT secret committed to your repo (general/hardcoded-secret), which is exactly the secret the insecure pattern tempts you to spread around.
FAQ
Is better-auth a supported Supabase auth provider? No. Supabase third-party auth supports only Clerk, Auth0, Firebase, Cognito, and WorkOS. There's no way to register better-auth (or any custom issuer) as a provider, so you enforce RLS server-side instead.
Should I sign better-auth tokens with my Supabase JWT secret?
No. That shares your project's forge-anything signing key with app code, and it relies on the deprecated symmetric-key flow. Use a restricted database role and set request.jwt.claims per request instead.
How does auth.jwt() work without a Supabase token?
auth.jwt() just reads current_setting('request.jwt.claims'). When you set_config('request.jwt.claims', …, true) on your own connection, auth.jwt() ->> 'sub' resolves to whatever you set — your better-auth user ID — and your policies enforce normally.
Can I store the better-auth user ID in a uuid column?
Only if better-auth is configured to generate UUIDs; by default its IDs are strings. Safest is user_id text compared to auth.jwt() ->> 'sub', matching how you'd handle Clerk or Auth0 IDs.
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.