
SECURITY PATTERNS
How to Store OAuth Tokens Securely (Without Leaking Secrets)
Where tokens belong in a backend, a browser and a native app, why a hash beats a ciphertext, and the provider-by-provider difference between deleting your copy and revoking the grant.
TL;DR
A leaked OAuth token does exactly as much damage as your storage let it, and the decision that moves that number most is one most teams never make: whether you keep the token at all, or only something derived from it.
RFC 6819's threat model says "store access token hashes only", and it works because a resource server verifying a token does not need the plaintext. GitHub's April 2022 disclosure is the clearest example of the payoff. An attacker abused OAuth user tokens stolen from two integrators, Heroku and Travis CI, "to download data from dozens of organizations, including npm", and GitHub's own statement was that it did not believe its systems were the source "because the tokens in question are not stored by GitHub in their original, usable formats".
The usual reading of token storage is that it is an encryption problem. It mostly is not. Encryption at rest protects a stolen disk; it does nothing about a process that can already call your decrypt path, and that process is what actually reads tokens in an incident.
We are not saying skip the encryption. Column-level encryption with a key outside the database is the right default, and RFC 9700 requires that a resource server "MUST treat access tokens like other sensitive secrets and not store or transfer them in plaintext".
Today: keep access tokens out of any datastore you do not have to keep them in, put refresh tokens behind
a key the database cannot reach, and wire your disconnect button to the provider's revocation endpoint
rather than to a DELETE.
Overview
Every OAuth integration ends with a token landing somewhere. A database column, a JavaScript variable, a file on a phone. Where it lands matters more than how it was issued, because an attacker who wants your API access does not need to break OAuth. They need to find where the result got put.
The failure is rarely exotic. In the GitHub case the compromised integrations were Heroku Dashboard and Travis CI, GitHub's initial detection on 12 April 2022 came from spotting unauthorized access to npm infrastructure with a stolen AWS key, and the April 27 update described the attacker's pattern plainly: authenticate with the stolen OAuth tokens, list "all the user's organizations", pick targets, list their private repositories, "then proceeded to clone some of those private repositories". No cryptography was broken anywhere in that chain.
This post walks the three environments that come up in practice, a server-side backend, a browser app and a native client, then covers the two things teams skip: sender-constraining, which makes a stolen token useless to the thief, and revocation, which is not the same act as deleting your row. The exchange that produces these tokens and the mechanics of refreshing them are covered in Slack OAuth in Python and OAuth refresh tokens. This one starts once you already hold the credential.
The threat model is not a broken cipher. It is a token sitting somewhere an attacker did not have to try hard to read, and a process holding decrypt rights it never needed.
What You're Actually Storing
Most providers hand back some combination of three things: an access token, short-lived and used directly against the API; a refresh token, long-lived and used to mint new access tokens without bothering the user; and, for a confidential client, a client secret identifying your application rather than any particular user.
All three are secrets, and the refresh token deserves the most caution because it has the longest reach. RFC 6819's threat catalogue treats them differently, and the difference is worth copying. For access tokens on a client it recommends you "keep access tokens in transient memory and limit grants". For an authorization server's database it recommends you "store access token hashes only". Those are two different instincts: do not persist, and if you must persist, persist something that cannot be replayed.
The environment variable question comes up constantly, so here is the answer from OWASP's secrets management guidance: environment variables are "generally accessible to all processes and may be included in logs or system dumps", which makes them unsuitable for anything long-lived. They are fine as a transport for a reference to a secret. They are a poor home for the secret.
Figure 1 — One safe answer per client type. Nothing covers all three, and the browser is the one with no fully safe option.
Backend: The Safest Place, With Conditions
Whenever the architecture allows it, keep tokens on the server and never let them reach the browser at all. That single decision removes a whole class of attack, because page JavaScript has nothing to steal.
The shape most teams land on is a table keyed by user and provider, with the secret columns encrypted under a key the database itself cannot reach:
CREATE TABLE oauth_tokens (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL,
provider TEXT NOT NULL,
access_ct BYTEA, -- envelope-encrypted, nullable
refresh_ct BYTEA NOT NULL, -- envelope-encrypted
wrapped_dek BYTEA NOT NULL, -- data key, encrypted under the KMS key
token_sha256 BYTEA, -- lookup + leak detection, not a secret
expires_at TIMESTAMPTZ,
last_refresh_at TIMESTAMPTZ,
UNIQUE (user_id, provider)
);Envelope encryption is the pattern worth understanding rather than copying blindly. In AWS KMS the hierarchy runs from a domain key held only in HSM memory, down to an HSM backing key that is "designed never to be exported from the HSM in plaintext", down to customer data keys that your application does hold. Domain keys rotate daily; the backing key rotates yearly when you enable rotation. What you get from that is not secrecy of the data key, which your process has in memory anyway. It is that a stolen database dump contains only wrapped keys, and unwrapping them is an audited API call from an identity you can revoke.
The token_sha256 column is the part usually missing. Store the hash even when you also store the
ciphertext, because it gives you two things the ciphertext cannot: a constant-time lookup for "do we
already have this token", and a way to answer "is this leaked string one of ours" without ever decrypting.
If your service is a resource server rather than a client, drop the ciphertext entirely and keep only the
hash, which is what RFC 6819 has recommended all along.
Two disciplines matter as much as the schema. Tokens never appear in logs, traces or error messages; a
stack trace that prints a request object with an Authorization header has just written a live credential
into a log aggregator with looser access control than the database. And RFC 9700 is explicit that clients
"MUST NOT pass access tokens in a URI query parameter", partly for this reason: query strings end up in
access logs, referrer headers and browser history.
Browser Apps: No Truly Secure Storage
For a single-page app, what OAuth calls a public client, there is no place in the browser that is safe. OWASP's HTML5 guidance puts it about as bluntly as a standards body gets: "A single Cross Site Scripting can be used to steal all the data in these objects", and "Do not store session identifiers in local storage as the data is always accessible by JavaScript". The same page notes the reverse risk, that XSS can also write to those objects, so nothing read back from them should be trusted.
The pattern that avoids the problem instead of managing it is to move the token out of the browser
entirely. Keep the access token in memory if the SPA needs one, put a session reference in an HttpOnly,
Secure, SameSite cookie the page cannot read, and have your backend hold the real tokens and make the
provider calls.
Set-Cookie: rt_session=abc123;
HttpOnly;
Secure;
SameSite=Strict;
Path=/api/;
Max-Age=1209600Figure 2 — The backend-for-frontend shape. XSS can still act as the user through your API, which is why the cookie is scoped and the backend filters.
HttpOnly is the load-bearing flag, and it is worth being honest about its limits. It stops a token
walking out through document.cookie or an extension's storage scanner. It does not stop an attacker
with script execution from making requests as the logged-in user. That is why the backend should expose
only the operations the page actually needs rather than a generic provider proxy.
Mobile and Desktop: Use the OS Store, and Check Its Status
Native platforms ship a secure store built for this, and the implementation detail is better than most teams assume. Apple's platform security documentation describes keychain items as "encrypted using two different AES-256-GCM keys: a table key (metadata) and a per-row key (secret key)", with the metadata key protected by the Secure Enclave and cached for fast queries while "the secret key always requires a round trip through the Secure Enclave". Access control lists are evaluated inside the Secure Enclave and released to the kernel "only if their specified constraints are met".
Pick the protection class deliberately, because the default is not the tightest. A refresh token that
only ever gets used while someone is looking at the app belongs under kSecAttrAccessibleWhenUnlocked;
one that must survive a background refresh needs kSecAttrAccessibleAfterFirstUnlock. The
ThisDeviceOnly variants are the ones that stop a credential riding a backup onto a different device.
Android is the case where the advice changed and a lot of tutorials have not. Every API in
androidx.security:security-crypto, including EncryptedSharedPreferences, EncryptedFile and
MasterKeys, was deprecated in 1.1.0-beta01 on 4 June 2025, with the release note reading "Deprecated
all APIs in favour of existing platform APIs and direct use of Android Keystore". Version 1.1.0 shipped
stable on 30 July 2025 and is still deprecated. If you are starting now, go to the Keystore directly.
RFC 8252 supplies the other half of the native story and it is the half people break. Native apps "MUST NOT use embedded user-agents to perform authorization requests", they are public clients, and any secret shipped in the binary is not a secret: "Secrets that are statically included as part of an app distributed to multiple users should not be treated as confidential secrets."
Sender-Constrained Tokens
Everything above reduces the chance of a token leaking. Sender-constraining changes what a leak is worth, and it is the part of the current guidance that most codebases have not adopted.
RFC 9700 makes it a requirement in one place and a recommendation in another. Requirement: "Refresh tokens for public clients MUST be sender-constrained or use refresh token rotation." Recommendation: servers "SHOULD use mechanisms for sender-constraining access tokens, such as mutual TLS" or DPoP. Most teams satisfy the first with rotation, which is the easier half, and skip the second entirely.
DPoP, RFC 9449, binds a token to a key the client proves it holds on every request. The token response
comes back with "token_type": "DPoP" rather than Bearer, and the server can hand out a DPoP-Nonce
to keep proofs fresh and stop an attacker pre-generating them. The RFC describes the benefit as "defense
in depth against the impact of unanticipated token leakage", which is the right framing. It does not stop
the leak. It makes the stolen string insufficient on its own.
| Mechanism | What it costs you | What a stolen token is worth |
|---|---|---|
| Bearer token, no binding | Nothing | Full access until expiry or revocation |
| Refresh token rotation | A per-connection lock, or you cause your own outages | One use, then the replay is detected |
| DPoP (RFC 9449) | A key per client, proof signing on every call, nonce handling | Nothing without the private key |
| Mutual TLS (RFC 8705) | Certificate provisioning and rotation | Nothing without the client certificate |
Table 1 — The four options, in ascending order of how little a thief gets.
If you take one thing from this table, take the second row's parenthetical. Rotation is the default answer and it is correct, but it converts a concurrency bug in your own code into a revoked grant.
Deleting Your Copy Is Not Revoking Theirs
This is the most common quiet mistake in the whole topic. Clearing a token from your database stops your app from using it. It does nothing to the grant the token represents on the provider's side.
RFC 7009 defines the endpoint that does: a POST with token and an optional token_type_hint. Two
details in it are worth knowing. The response is 200 "if the token has been revoked successfully or if
the client submitted an invalid token", so a 200 is not evidence anything existed. And revoking the
refresh token is the higher-leverage call, because "if the particular token is a refresh token and the
authorization server supports the revocation of access tokens, then the authorization server SHOULD also
invalidate all access tokens based on the same authorization grant".
| Provider | Call | What it actually does |
|---|---|---|
POST https://oauth2.googleapis.com/revoke with token= | 200 means revoked; anything else is a failure you should surface | |
| Slack | auth.revoke with the token | Returns a revoked boolean, and test=1 lets you dry-run it |
| Microsoft Entra | POST /users/{id}/revokeSignInSessions | Invalidates all refresh tokens and browser session cookies for that user |
| Any RFC 7009 server | POST /revoke with token, token_type_hint=refresh_token | Kills the refresh token, and should cascade to access tokens on the same grant |
Table 2 — Revocation is a provider call, not a database operation. Four of them, and what each one covers.
Two gotchas in that table. Microsoft's documentation says plainly that "after you call
revokeSignInSessions, there might be a small delay of a few minutes before tokens are revoked", and
that it does not revoke sign-in sessions for external users, who authenticate in their home tenant. And
Slack's revoked field exists because the call can succeed while revoking nothing, which is exactly the
case a naive integration reports as a successful disconnect.
Wire the disconnect button to the provider, then to your database, in that order. A user who clicks disconnect believes access has stopped. Deleting the row first and failing the revoke call leaves you with no token to revoke and a grant that is still live.
On our side of this, Agentic Fabriq holds credentials per user in a vault and attaches them server-side at the moment of the call, so the agent never handles the secret, and refresh runs behind that boundary with last-used tracking. The honest limit is the same one this section is about. Clearing a vault copy is not the same act as invalidating a grant upstream, so the provider call above remains yours to make.
Operational Practices
Keep access tokens short-lived and lean on refresh for anything long-running, because a token that leaks
is only useful while it is valid. Record the absolute expiry computed at receipt time rather than the
expires_in you were given, and record the timestamp of each refresh; the second field is what later
tells you whether a dead grant died of inactivity or of something you did.
Then build the two detections that pay for themselves. A grep of your log pipeline for anything shaped
like a token, run on a schedule, catches the stack trace nobody noticed. And the token_sha256 column
turns a leaked-credential report into a query instead of an investigation.
| Symptom | Likely cause | How to confirm |
|---|---|---|
| Disconnect leaves provider access live | Row deleted, revocation endpoint never called | Check the provider's connected-apps page after a disconnect |
| Token in a log aggregator | Authorization header serialized in an error path | Search logs for your provider's token prefix |
| Refresh works from staging with prod tokens | One encryption key shared across environments | Try decrypting a prod ciphertext with the staging key |
| Revocation "succeeds" but access continues | 200 returned for an invalid token, or provider-side delay | Re-read the token's validity, and wait out the documented delay |
Table 3 — Four storage failures and the cheapest way to tell which one you have.
Keep secrets out of source control and CI logs the same way you would a password, and use a secrets manager for anything that has to exist at deploy time.
Quick Checklist
- Access and refresh tokens live only on a backend or in an OS secure store, never in
localStorage. - Secret columns are envelope-encrypted with the data key wrapped by a KMS key the database cannot reach.
- A SHA-256 of each token is stored for lookup and leak matching; resource servers store the hash alone.
- Browser apps hold a session reference in an HttpOnly, Secure,
SameSitecookie and nothing else. - Native apps use the Keychain or the Android Keystore directly, with a protection class chosen on purpose.
- Refresh tokens rotate, or are sender-constrained with DPoP or mutual TLS.
- Disconnect calls the provider's revocation endpoint before deleting the local row.
- No token ever appears in a log, a trace, a URI query parameter or an error message.
Frequently Asked Questions
Is it safe to store OAuth tokens in localStorage? No. OWASP's guidance is that the data there "is
always accessible by JavaScript", so one XSS flaw reads everything. Keep the access token in memory and
put a session reference in an HttpOnly cookie instead.
Should I encrypt tokens in the database? Yes, at the column level, with the key held outside the database. RFC 9700 requires that access tokens not be stored or transferred in plaintext. Volume encryption alone does not satisfy the interesting threat, which is a leaked backup or a misconfigured replica.
Can I store a hash instead of the token? If you are the resource server validating tokens, yes, and RFC 6819 recommends exactly that: "store access token hashes only". If you are a client that has to present the token to somebody else, you need the recoverable value, so store both the ciphertext and a hash for lookup.
Does deleting the token revoke access? No. It stops your application from using it and leaves the grant intact on the provider's side. You need a call to the provider's revocation endpoint, and under RFC 7009 revoking the refresh token is the one that should cascade to access tokens on the same grant.
How long until revocation takes effect? Provider-dependent, and not always immediate. Microsoft
documents "a small delay of a few minutes" after revokeSignInSessions. Plan for a window in which a
token you have revoked still works.
Where do I put tokens in a mobile app? The platform store. iOS Keychain items are encrypted with
per-row AES-256-GCM keys and the secret key round-trips through the Secure Enclave. On Android, go
straight to the Keystore, since every API in androidx.security:security-crypto was deprecated in June
2025 in favour of the platform APIs.
Is a client secret in a mobile app safe if I obfuscate it? No. RFC 8252 settles it: a secret statically included in an app distributed to many users "should not be treated as confidential". Use PKCE and a public client.
What is DPoP and do I need it? It binds a token to a key the client proves on every request, so a stolen token alone is useless. RFC 9700 requires refresh tokens for public clients to be sender-constrained or rotated; DPoP is how you do the first. If you are already rotating correctly, treat DPoP as the next increment rather than an emergency.
Conclusion
None of this needs exotic cryptography, and the parts that look like cryptography are mostly key custody. The question is the same one asked three times, once per environment: where does this secret sit, and what can read it from there.
Get the backend case right and most of the risk disappears, because nothing sensitive reaches a browser. Get the browser case right by refusing to store the token there at all. Get the native case right by using what the OS already built, and by reading its release notes, because the recommended Android answer changed in 2025. Then do the two things nearly everyone skips: keep a hash so a leak report becomes a query, and make disconnect mean revoke.
A token you cannot name the storage location for is a token you should assume is already exposed. Storage is not the interesting part of OAuth, which is exactly why it is where the damage happens.
Sources
All URLs read 2026-09-29.
- RFC 6819: OAuth 2.0 Threat Model and Security Considerations — "keep access tokens in transient memory and limit grants" for clients, and "store access token hashes only" for server databases.
- RFC 9700: Best Current Practice for OAuth 2.0 Security — refresh tokens for public clients must be sender-constrained or rotated, sender-constraining via mTLS or DPoP, no tokens in query parameters, and the plaintext-storage prohibition.
- RFC 8252: OAuth 2.0 for Native Apps — native apps as public clients, the ban on embedded user-agents, and why a shipped secret is not a secret.
- RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP) — sender-constraining, the
DPoPtoken type, and theDPoP-Noncemechanism. - RFC 7009: OAuth 2.0 Token Revocation — the revocation request, the 200 response for invalid tokens, and the cascade from refresh token to access tokens.
- Security alert: stolen OAuth user tokens, GitHub — the April 2022 Heroku and Travis CI incident, the attacker's enumeration pattern, and GitHub not storing tokens in usable form.
- OWASP HTML5 Security Cheat Sheet — why
localStorageis readable by any script and what must not go in it. - OWASP Secrets Management Cheat Sheet — environment variables as a poor home for secrets, and the case for short-lived dynamic credentials.
- Keychain data protection, Apple Platform Security — the two AES-256-GCM keys, the Secure Enclave round trip, and the
kSecAttrAccessibleprotection classes. - androidx.security release notes, Android Developers — all
security-cryptoAPIs deprecated in 1.1.0-beta01 on 4 June 2025 in favour of direct Android Keystore use. - AWS KMS concepts, key hierarchy — envelope encryption, HSM backing keys never exported in plaintext, and the domain-key rotation schedule.
- Using OAuth 2.0 for Web Server Applications, Google — the
https://oauth2.googleapis.com/revokeendpoint and the 200-means-revoked contract. - auth.revoke, Slack API — the
revokedboolean and thetestdry-run parameter. - user: revokeSignInSessions, Microsoft Graph — what it invalidates, the documented few-minute delay, and the external-user exception.