Watercolor of a child on a swing framed by trees
← Back to blog

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.

•Nov 23, 2025•Updated Sep 29, 2026•16 min
SecurityOAuthArchitecture

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.

Where does the token need to live?

Server-side backend

Browser-based app

Mobile or desktop app

envelope-encrypted column,
data key wrapped by a KMS key

access token: memory only
session ref: HttpOnly cookie
backend holds the real token

OS secure store
Keychain / Android Keystore

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:

sql
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.

http
Set-Cookie: rt_session=abc123;
  HttpOnly;
  Secure;
  SameSite=Strict;
  Path=/api/;
  Max-Age=1209600
Provider APIToken storeYour backendPage (JS)Provider APIToken storeYour backendPage (JS)fetch /api/messages (cookie only)load tokens for this session's useraccess token (decrypted in memory)GET /messages + Authorization200200, filtered result

Figure 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.

MechanismWhat it costs youWhat a stolen token is worth
Bearer token, no bindingNothingFull access until expiry or revocation
Refresh token rotationA per-connection lock, or you cause your own outagesOne use, then the replay is detected
DPoP (RFC 9449)A key per client, proof signing on every call, nonce handlingNothing without the private key
Mutual TLS (RFC 8705)Certificate provisioning and rotationNothing 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".

ProviderCallWhat it actually does
GooglePOST https://oauth2.googleapis.com/revoke with token=200 means revoked; anything else is a failure you should surface
Slackauth.revoke with the tokenReturns a revoked boolean, and test=1 lets you dry-run it
Microsoft EntraPOST /users/{id}/revokeSignInSessionsInvalidates all refresh tokens and browser session cookies for that user
Any RFC 7009 serverPOST /revoke with token, token_type_hint=refresh_tokenKills 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.

SymptomLikely causeHow to confirm
Disconnect leaves provider access liveRow deleted, revocation endpoint never calledCheck the provider's connected-apps page after a disconnect
Token in a log aggregatorAuthorization header serialized in an error pathSearch logs for your provider's token prefix
Refresh works from staging with prod tokensOne encryption key shared across environmentsTry decrypting a prod ciphertext with the staging key
Revocation "succeeds" but access continues200 returned for an invalid token, or provider-side delayRe-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, SameSite cookie 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.