Encrypt OAuth Tokens at Rest: AES-256-GCM for Fintech
Storing OAuth tokens in plaintext is a financial breach waiting to happen. Here's the AES-256-GCM pattern I use to encrypt tokens at rest, with rotation.
By Mike Hodgen
What's Actually Sitting in Your Tokens Table
I built a finance app for small e-commerce businesses. It connects to a company's accounting platform, their store, and their ad accounts so the owner can see profit, revenue, and ad spend in one place. To do that, the app stores OAuth access and refresh tokens in the database.
Here's the part that should make you uncomfortable. Those tokens are not session cookies. They are live keys to a company's books, their transaction history, and their ad budget. If you want to encrypt OAuth tokens at rest, the first step is admitting what those rows actually are.
The credential nobody treats like a credential
The OAuth library hands you a string. You save the string. That's the default flow in almost every app I've reviewed, and it's how most teams end up with a plaintext tokens table without ever deciding to.
Nobody sits in a meeting and says "let's store the keys to our customers' bank-adjacent data in plaintext." It just happens, because the library makes saving the string the path of least resistance.
One leak equals full financial access
A plaintext tokens table is one database exposure away from a full financial breach. Anyone who reads a single row can pull a company's P&L, scrape their transaction history, and spend their entire ad budget before lunch.
I see this pattern constantly. It's one of the five security holes I find in every AI-built app. The fix is not "we'll secure the database later." The fix is field-level encryption, applied to the token column, from the first write.
Why Plaintext Tokens Are a Worse Risk Than Most Passwords
People assume token storage works like password storage. It doesn't, and the difference is the whole reason this matters.
You can't hash a token, you have to use it
You hash a password because you only ever need to verify it. A user types it in, you hash the input, you compare the hashes. You never read the original back. A one-way function is perfect for that.
Hashing vs Encryption for Tokens
OAuth tokens are the opposite. You have to send the actual token to the provider every time you call their API. That means the value must be reversible, which means encryption, not hashing. There is no "just hash it" option here. If you can't recover the original string, the token is useless.
Refresh tokens are long-lived skeleton keys
Access tokens expire in an hour or so. The real danger is the refresh token. It's long-lived and it can mint new access tokens indefinitely, until someone explicitly revokes it.
A stolen refresh token is a standing back door. It doesn't expire on its own. An attacker who grabs one can keep pulling fresh access tokens for months.
Now picture the threat model concretely. SQL injection. A leaked database backup sitting in an old S3 bucket. An exposed anon key. A misconfigured row-level security policy. A disgruntled insider with read access.
In every one of those scenarios, plaintext tokens mean immediate financial access. The attacker reads the row and they're in. With encryption at rest, that same attacker now also needs the encryption key, and the key lives somewhere else entirely. That gap is the core of real fintech token encryption. You're not making a breach impossible. You're making a database leak insufficient on its own.
The AES-256-GCM Field Encryption Pattern
The solution is field-level AES-256-GCM field encryption. It's the same module I use for health data, just pointed at a different column. If you want the medical-data version of this, I wrote up the same AES-256-GCM module I use for health data. The crypto is identical. Only the field changes.
Encrypt on write, decrypt on read
The shape is simple. On write, you encrypt the token string before it touches the database. You store the ciphertext, never the plaintext. On read, you decrypt only at the exact moment you need to call the provider, hold the plaintext in memory just long enough to make the request, and let it go.
The database column contains ciphertext and nothing else. The app layer does all the crypto. This is the practical answer for anyone trying to supabase encrypt sensitive data: Postgres stores the encrypted blob, your application handles encryption and decryption in code.
The encrypt function returns a single string in the shape iv:tag:ciphertext. The decrypt function splits that apart and reverses the process. Store the whole string in one column and you've got everything you need to recover the token later.
Why GCM and not CBC
GCM is authenticated encryption. That word "authenticated" is the reason I use it.
If an attacker tampers with the stored ciphertext, decryption fails loudly. You get an error, not garbage. Older modes like CBC will happily decrypt manipulated data into nonsense and hand it back to your code as if nothing happened. With GCM, the auth tag catches tampering before you ever try to use the result.
Each encryption also uses a fresh random initialization vector. Same token, encrypted twice, produces two different ciphertexts. That stops anyone from spotting patterns across rows.
Key management and where the key lives
This is the part people skip, and it's the part that matters most. The encryption key never lives in the database alongside the data it protects.
AES-256-GCM Encrypt on Write, Decrypt on Read Architecture
The key lives in an environment secret or a dedicated secrets manager. A database dump on its own is useless ciphertext. To decrypt anything, an attacker needs both the dump and the key, and those two things live in different systems with different access controls. That separation is the entire point. Lose one, you're still protected.
The Refresh Path: Re-Encrypting Rotated Tokens
This is where real implementations separate themselves from demos. It's also the part that makes token encryption trickier than encrypting health data, because tokens change underneath you.
Rotation breaks naive encryption setups
Many providers rotate the refresh token every single time you use it. You send the old refresh token, you get a new access token and a brand new refresh token back. The old refresh token is now dead.
Here's the trap. If your refresh flow receives that new refresh token and forgets to re-encrypt and persist it, one of two bad things happens. Either you lose access entirely, because your next refresh uses a dead token, or worse, someone writes the new token back in plaintext to "fix" the breakage and undoes all your encryption work in one commit.
I've seen both. The second one is more common because it looks like a bug fix.
Persist the new refresh token within minutes of expiry
The correct path is precise. Detect that the access token is near expiry. Decrypt the stored refresh token. Call the provider. Receive the new access token and possibly a new refresh token. Re-encrypt both. Write them back atomically. All of this happens within minutes of expiry, automatically, with no user involved.
Refresh Token Rotation Flow
The rotated refresh token must be re-encrypted before it touches the database. Not after. Not in a follow-up job. Before.
And the write has to be transactional. If a rotation half-completes, you store a new access token but fail to store the new refresh token, the account is now disconnected and the customer has to manually reconnect their accounting platform. For a small e-commerce owner, that's a frustrating, confusing failure. Wrapping the write in a transaction is how you secure store refresh token values without leaving accounts stranded.
Audit Logging Every Token Access
Encryption protects the data at rest. Audit logging tells you what's happening to it.
Who decrypted what, and when
Every time the app decrypts a token, it writes an audit record. Which connection, which provider, a timestamp, and what triggered the decrypt, whether that was a scheduled sync, a user-initiated refresh, or a background job.
What you do not log is the token value itself. You're recording the access event, never the secret. Logging the plaintext token into an audit table would recreate the exact problem you just solved.
Detection beats hope
Two things come out of this. First, you get a forensic trail. If something goes wrong, you can reconstruct exactly which tokens were touched and when, instead of guessing.
Second, you can spot anomalies. A token decrypted 400 times in an hour is not normal. Decryptions coming from a job that shouldn't be touching that connection is a signal. Without the log, you find out something's wrong when a customer's ad budget is gone. With it, you can catch the pattern early.
Let me be honest about the limit. An audit log doesn't prevent a breach. It shrinks the blast radius and cuts your time-to-detect from "never" to "minutes." For a finance buyer, it's also the first thing a security reviewer asks to see.
Backfilling Tokens Stored Before the Pattern Existed
Real talk: this pattern usually arrives after some tokens are already sitting in the database in plaintext. The encryption work is the easy part. The migration is the part nobody wants to write.
The migration nobody wants to write
You can't just truncate the table and ask every customer to reconnect their accounting platform and ad accounts. For small e-commerce businesses, that reconnect flow is confusing and many of them will simply churn instead of doing it. That's a support nightmare and a revenue hit.
So the migration has to be silent and lossless. The customer should never know it happened.
Encrypt in place, verify, then enforce
The backfill reads each plaintext token, encrypts it, writes the ciphertext back, and marks the row as migrated. Make it idempotent so you can re-run it safely if it dies halfway through. A migration you're afraid to re-run is a migration that will fail at 2am and stay broken.
Plaintext Token Backfill Migration Sequence
Before you flip the app to assume everything is encrypted, verify a sample. Pull a handful of migrated rows, decrypt them, confirm the tokens still work against the provider. Only after that verification do you block plaintext writes and stop accepting unencrypted input.
One thing that doesn't work cleanly, and I'll say it plainly: if you ever rotate the encryption key, you need another re-encrypt pass over every row. Design the column to tolerate a key version from the start. Store which key encrypted each row. Future-you, mid key rotation, will be grateful you did. While you're at it, this is also the right moment to think about how I split tokens across two tables to avoid an RLS leak, because encryption and storage layout are two halves of the same problem.
What a Security Review Actually Wants to See
If your app holds credentials to someone else's financial accounts, a security review is coming. A finance or regulated company evaluating your product will ask, in writing, where connected-account credentials live and whether they're encrypted at rest.
The questions a fintech buyer will ask
Here's what this pattern lets you answer without flinching. Tokens are encrypted with AES-256-GCM. The key is stored outside the database, in a secrets manager. Token rotation is handled, and rotated tokens are re-encrypted before they're persisted. There's an audit trail of every decrypt. The backfill is complete, so no plaintext tokens remain.
Five Security Review Questions and Answers
Those are five clean answers to five hard questions. A reviewer who hears them moves on.
Encryption is table stakes, not a feature
This is not a premium add-on you bolt on after a vendor questionnaire lands in your inbox. I build it into financial integrations from the start, because retrofitting encryption onto a live tokens table under deadline pressure is exactly how plaintext tokens end up "temporarily" rewritten into production.
If your app holds the keys to someone else's bank, books, or ad accounts, and you're not certain those tokens are encrypted at rest, that's the kind of gap I find and fix. I build financial integrations that pass a security review, and I'd rather find this problem before a buyer's reviewer does.
Ready to bring AI leadership into your company?
I work with a small number of companies at a time. If you're serious about AI, apply to work together and I'll review your application personally.
Get AI insights for business leaders
Practical AI strategy from someone who built the systems — not just studied them. No spam, no fluff.
Hodgen.AI
Ready to automate your growth?
Book a free 30-minute strategy call with Hodgen.AI.
Book a Strategy Call