Encrypt OAuth Tokens in Supabase Without an RLS Leak
How to encrypt OAuth tokens in Supabase using a two-table split, a service-role-only secrets table, and AES-256-GCM. The RLS pattern that stops credential leaks.
By Mike Hodgen
The Setup: My AI App Holds a Client's CRM Keys
I built an AI ops platform for a law firm that plugs straight into their case-management system. The platform reads matters, drafts updates, files documents, syncs status. Useful work. And to do any of it, the platform holds the firm's OAuth access token, their refresh token, and the webhook signing secrets that verify inbound events.
Sit with that for a second. If those credentials leak, an attacker can read or write to the firm's entire case load. Privileged client communications. Settlement figures. Everything.
When you let a vendor connect to your systems, you are not just trusting their AI. You are trusting their database design. The most boring, unglamorous part of their stack is the part holding the keys to your kingdom. And most vendors get this wrong without ever knowing it, because the wrong design looks completely fine in a demo.
Here is the trap. The obvious way to store integration credentials is one table. Call it firm_integrations. It holds the connection status, the provider name, the last sync time, and the encrypted tokens, all in one tidy row per firm. Then you turn on Supabase row-level security, write a sensible policy so each firm sees its own row, and you feel done.
That design is quietly broken. Not "could be improved" broken. Leaking-the-ciphertext-to-every-employee broken.
This is the pattern I want to walk through, because if you are evaluating whether to encrypt OAuth tokens in Supabase the right way, or evaluating a vendor who claims they already do, the table layout matters more than the encryption algorithm. Let me show you exactly where the single-table design springs a leak, and the two-table pattern that closes it.
Why a Single Table With RLS Still Leaks the Ciphertext
The whole problem comes from a misunderstanding of what RLS actually controls.
Single-table RLS leak: how the ciphertext escapes
RLS is row-level, not column-level
Row-level security does exactly what the name says. It decides which rows a given role can see. It does not decide which columns. Once a policy says "this firm member can read this row," they can read every column in that row.
So picture the single firm_integrations table again. Firm members genuinely need to read their own integration row, because the UI has to show a green "Connected" badge and a "last synced 4 minutes ago" timestamp. Reasonable requirement. You write a policy that lets authenticated firm members select their own rows.
You just granted every junior paralegal read access to the encrypted_token and webhook_secret_hash columns sitting in that same row.
PostgREST exposes every readable column
Supabase auto-generates a REST API over your tables through PostgREST. That is the convenience people love. It is also the part people forget is public-facing.
Any authenticated firm member can skip your app entirely, hit the auto-generated endpoint directly with their own token, and SELECT whatever columns the row policy lets them reach. There is no separate gate on the sensitive columns. This is the same mechanism behind so many databases readable by anyone with the URL: a query nobody intended to expose, pulling data nobody meant to ship.
"But it's encrypted." Sure. Now every employee has the ciphertext and the full schema describing how it was stored. They can pull the blob, study the structure, and attack it offline at their leisure with no rate limit and no audit trail. Encryption is not the fix here. Table design is. Row-level access plus a column you never meant to expose equals a leak, full stop.
The Fix: Split Status From Secrets Into Two Tables
The correct pattern is almost embarrassingly simple once you see it. Stop mixing the two kinds of data. They have completely different access needs, so they belong in completely different tables.
Two-table split: status table vs deny-all secrets table
A member-readable status table
Table one holds the stuff the UI needs. firm_id, provider, connection state, last_sync timestamp, maybe an error summary. None of it is a secret. A paralegal reading "Connected, last synced 4 minutes ago" is exactly what should happen.
RLS on this table lets firm members read their own rows. Write the friendly policy here, where it belongs. The whole table is designed to be read by humans through the app, so expose it deliberately and stop worrying about it.
A service-role-only secrets table with RLS on and zero policies
Table two holds the dangerous stuff. firm_id, provider, the encrypted token blobs, the key version. Nothing else. And here is the move that makes it work: enable RLS, then write zero policies.
In Postgres, RLS enabled with no policy means deny-all for the API roles. The anon and authenticated roles cannot reach a single row through PostgREST. Not their own. Not anyone's. The table is physically unreachable from the public API.
The only role that can touch it is the service_role, which bypasses RLS by design. And the service role only ever runs in my server-side functions. It never ships to the browser, never lands in client code, never appears in a network request a user could inspect.
That is what a service role only table means in practice. The secrets live behind a wall that the public API has no door through. A leaked anon key, a curious employee, a misconfigured frontend, none of them can read the secrets table, because there is no policy that grants them a row to read.
This is least-privilege made concrete instead of aspirational. The status table is open to the people who need it. The secrets table is closed to everyone except the server. You did not write a clever permission rule. You removed the permission entirely and only the trusted server-side role can bypass it. This is what data isolation as a default looks like at the schema level, and it is the foundation everything else sits on.
AES-256-GCM With Composite AAD So a Stolen Blob Is Useless
The secrets table is already unreachable from the API. So why encrypt at all?
Because when you hold someone else's keys, you wear both belt and suspenders. A database backup gets misplaced. A future engineer adds a careless policy. A logical replica leaks. I assume one of those eventually happens and I design so the blob is still useless when it does. The deeper mechanics of how I encrypt them at rest are worth a read on their own, but here is the part specific to multi-tenant token storage.
Binding each ciphertext to firm + provider + slot
I use AES-256-GCM with Additional Authenticated Data. AAD is data that gets authenticated during encryption but is not itself encrypted. If the AAD presented at decryption does not match what was used at encryption, the decrypt fails outright.
AES-256-GCM with composite AAD welded to firm + provider + slot
I bind each ciphertext to a composite of firm_id, provider, and the token slot (access versus refresh). So the access token for Firm A's case system can only ever decrypt when those exact three values are supplied.
Now imagine an attacker who somehow grabs the table and tries to swap Firm A's encrypted blob into Firm B's row, hoping the system decrypts it in Firm B's context. It fails. The AAD does not match. The blob can only ever decrypt in the precise context it was written. You cannot move it, copy it, or replay it into another tenant. The encryption is welded to the row's identity.
A versioned key prefix for rotation
I prepend a version tag to every blob, something like v1: ahead of the ciphertext. That small detail solves the rotation nightmare.
When I need to rotate the master key, I do not run a terrifying flag-day migration that re-encrypts 600 rows at once and prays nothing breaks mid-flight. New writes use v2: with the new key. Old reads see the v1: prefix and resolve the old key by version. Both keys coexist. Rows migrate naturally as they get rewritten, or I backfill on my own schedule with zero downtime.
This is belt-and-suspenders, and I will say it plainly: the table split already does the heavy lifting. But when you are holding a firm's keys to their entire case load, "probably unreachable" is not a sentence I am willing to write into a contract.
The Bug I Caught: Tokens in a Plaintext 30-Day Cookie
Here is the part where I tell on myself, because finding your own mistakes is the whole job.
The plaintext cookie bug and the server-side fix
Midway through this build, I audited the OAuth callback flow and found something ugly. The raw tokens were being stashed in a plaintext browser cookie with a 30-day expiry.
Read that again. I had a deny-all secrets table, AAD-bound AES-256-GCM, versioned key rotation, all of it airtight on the database. And the actual secret was riding along in the clear, on the client, for a month. Every control I just described was pointless, because the token never needed to be attacked in the database. It was sitting in the browser.
The fix was straightforward once I saw it. Tokens never leave the server. The cookie holds nothing but an opaque session reference, a meaningless pointer that only my server can resolve. The real credential stays server-side, full stop.
Structural error redaction so a token never lands in a log
Then I closed the second door, because a token can leak through an error message just as easily as through a column or a cookie.
Picture a token refresh that fails. The natural reflex is to log the error, maybe write it to a last_error field on the status table so the UI can show what went wrong. And the error payload from the provider sometimes contains the token you just tried to use. Now your secret is serialized into a column that firm members can read, right back in the table you carefully kept clean.
So I redact known-sensitive shapes structurally before anything writes to a log or an error field. A failed refresh can never serialize the refresh token into the last_error column, because the redaction runs on the way out, on every path, not as a thing I remember to do.
I catch these because I assume I made them. This was one slice of a security audit across my codebases where I go looking for exactly this kind of thing on purpose. The buyer's takeaway is simple: I find my own mistakes before they ship.
Proving It: An RLS-Assertion Test Script
A claim that the secrets table is locked down is worth nothing if I cannot prove it on demand. So I wrote a test that proves it.
RLS-assertion test gating the CI deploy pipeline
The script authenticates as a normal firm-member role, the same role a real paralegal would have. Then it attacks. It tries to SELECT from the secrets table. It tries to read the token column specifically. It walks the various PostgREST routes that might reach the data through a join, an embed, or a filtered query.
The test passes only when every one of those attempts is denied. A single successful read fails the build.
This is the difference between a vendor's promise and a vendor's proof. Anyone can say "your tokens are isolated." I can show you a green check that turns red the instant someone adds a careless policy or moves a column back into the wrong table.
And it runs in CI, on the deploy gate, not as a one-time check I ran once and forgot. If a future version of this app accidentally exposes the secrets table, the build stops before it reaches production. The rls security pattern is not just designed once and trusted forever. It is continuously asserted, every deploy, automatically.
That converts "trust me, it's locked down" into a repeatable assertion that the whole pipeline depends on.
What This Says About a Vendor Holding Your Keys
When an AI platform connects to your case-management system, your CRM, your bank feed, it is holding keys that can do real damage in the wrong hands. That is not a hypothetical. That is the deal you make every time you connect a vendor.
So the question to ask is not "do you encrypt." Everyone says they encrypt. Encryption is table stakes and, as I showed you, encryption alone does not even close the leak.
Ask three sharper questions. Is the credential reachable by anyone but the server. Does the design assume mistakes will happen. Can they prove it with a test that fails the build. Most vendors cannot answer all three, and many have never thought about the first one because their single-table design demos perfectly.
I build the isolation in from the first table, not bolted on after a breach. The secrets table is deny-all by construction, the encryption is welded to the tenant, the secret never touches the client, and a failing test guards all of it on every deploy.
If you are weighing whether to let an AI system touch your client data, that is the rigor to ask about. And I am the person who builds these systems, not just the person who advises on them from a slide.
Want to explore what AI could do for your business?
Book a free 30-minute strategy call. No pitch deck, no sales team, just a real conversation about your operations and where AI fits.
Get AI insights for business leaders
Practical AI strategy from someone who built the systems — not just studied them. No spam, no fluff.
Ready to automate your growth?
Book a free 30-minute strategy call with Hodgen.AI.
Book a Strategy Call