Engineering
OAuth token storage: encrypting what you can't afford to leak
Refresh tokens are long-lived credentials to someone else's account. Envelope encryption, scoping per run, and the refresh race that corrupts them.
OAuth token storage is the part of an integration nobody demos. A refresh token is a long-lived credential to someone else’s Gmail, and if your database leaks with tokens in plaintext, you haven’t had a data breach — your customers have. Here’s how to hold them, and the concurrency bug that quietly breaks the ones you hold correctly.
Ask for less at the start
The cheapest security control is not holding the capability at all.
Providers offer granular scopes and most integrations request far more than they use, because broad scopes are easier and the consent screen is a one-time cost. But every extra scope widens what a leak — or a successful prompt injection — can reach.
Request the minimum. Use incremental authorisation to ask for more when a feature actually needs it, rather than up front “just in case”. A consent screen that asks for read-only access converts better anyway.
Envelope encryption, not a column cipher
Encrypt tokens at rest. The naive version — one application key, AES-GCM, ciphertext in a column — is much better than plaintext and has a rotation problem: changing the key means decrypting and re-encrypting every row, usually while the service is running.
Envelope encryption fixes that. Generate a fresh data key per token, encrypt the token with it, then encrypt that data key with a master key held in a KMS. Store the ciphertext and the wrapped data key together.
Rotating the master key then means re-wrapping small data keys rather than touching token ciphertext. Your KMS also gives you an audit log of decrypt calls, which is genuinely useful during an incident: you can see what was accessed and when.
Two details that matter regardless of scheme:
Bind the ciphertext to its row. Use the user or connection id as additional authenticated data. Then a ciphertext copied from one row to another fails to decrypt, instead of silently authorising as the wrong account.
Never log the plaintext. Not in errors, not in traces, not in a debug branch someone adds later. This is the most common way tokens actually escape — not a database breach, but an exception handler that helpfully includes the request context.
The refresh race
This is the bug that bites everyone who gets the encryption right.
Two requests notice the access token is expired at the same moment. Both call the provider’s refresh endpoint with the same refresh token. Many providers rotate refresh tokens on use — so the first call succeeds and invalidates the old token, and the second call fails with an invalid grant. Now you’ve written a broken state and the user has to reconnect.
The fix is to serialise refresh per connection:
select * from connections where id = $1 for update;
-- re-check expiry INSIDE the lock: the other request may have just refreshed
Take a row lock, then check expiry again inside it. The second request finds a fresh token and never calls the provider. Add a refresh margin — refresh at 80% of lifetime rather than at expiry — so you’re not doing this under time pressure on every request.
And when a refresh genuinely fails with an invalid grant, mark the connection as needing reauthorisation and tell the user. Silently retrying a dead token produces an integration that appears connected and does nothing, which is worse than an obvious error.
Scope the token to the run, not the agent
For agent systems there’s a further step that changes the blast radius.
Don’t hand the stored token to the agent. Mint a short-lived, narrowed credential for each run — covering only the operations that run needs — and let it expire when the run ends. The long-lived refresh token stays in the vault, touched only by the service that mints.
Then a compromised run holds something nearly worthless: narrow, expiring, and useless outside its context. This is the single control that most reduces what any agent-side failure can do.
Revocation has to be real
When a user disconnects an integration, three things must happen: call the provider’s revocation endpoint, delete your stored tokens, and invalidate any in-flight run credentials derived from them.
Skipping the first is common and leaves a live grant on the provider’s side that your UI claims is gone. Skipping the third means a run in progress keeps working after the user thought they’d cut access.
Handle the reverse direction too: the user may revoke from the provider’s console. Your next API call will fail with an invalid grant, and the correct response is marking the connection dead — not retrying it forever.
The checklist
- Request minimum scopes; expand incrementally.
- Envelope-encrypt with a KMS-held master key.
- Bind ciphertext to its row with AAD.
- Never log plaintext tokens — including in error paths.
- Serialise refresh with a row lock; re-check inside it.
- Refresh at ~80% of lifetime, not at expiry.
- Mint narrow, short-lived credentials per run.
- On disconnect: revoke upstream, delete locally, kill in-flight.
- Treat invalid-grant as “needs reauth”, not as a retry.
Items 5 and 7 are the ones that separate an integration that works in a demo from one that works for a year.
See also: scoping what agents can do and deny-by-default table policies.