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. NextAuth / Auth.js is in the same boat — see NextAuth + Supabase RLS for the identical pattern.
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 28 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.