
SECURITY PATTERNS
Token storage patterns for backends, browser apps, and native clients, plus the difference between deleting your copy of a token and actually revoking it.
TL;DR
A leaked OAuth token does exactly as much damage as its storage let it. An access token sitting in a database with column-level encryption survives a lot of mistakes; the same token in browser localStorage survives none of them, because anything on the page that can run JavaScript can read it.
The pattern that holds up is simple and provider-agnostic: tokens live on a backend or in an OS-level secure store, never anywhere a script or a casual file read can reach. Browsers are the one environment with no fully safe option, so the fix there is architectural, not just a storage choice.
Revoking access in your own database and revoking it at the provider are two different actions, and treating the first as the second is a common, quiet mistake. Deleting your stored copy stops your app from using the token. It doesn't touch whatever session the token represents on the provider's side unless you call the provider's revocation endpoint too.
None of this is exotic. It's the same discipline you'd apply to a password, applied consistently to a credential that often outlives the login screen that issued it.
Every OAuth integration ends with a token landing somewhere: a database column, a browser variable, a file on a phone. Where it lands turns out to matter more than how it was issued, because an attacker who wants your API access doesn't need to break OAuth. They just need to find where the result got put.
This post is a walkthrough of storage patterns for the three environments that actually come up in practice: a server-side backend, a browser-based app, and a mobile or desktop client. Each has a different right answer, and none of them is "encrypt it and hope."
We're not covering the exchange that produces these tokens or how to refresh them once they expire; both get their own treatment elsewhere in this series. This one starts from the moment you already have an access token, a refresh token, or both, and have to decide where they live.
The threat model here isn't a broken cipher. It's a token sitting somewhere an attacker didn't have to try hard to read.
Most providers hand back some combination of three things once the exchange completes: 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 again; and, if your app is a confidential client, a client secret that identifies your application to the OAuth server rather than any particular user.
All three are secrets, full stop, and belong exactly where you'd put a password: never in source control, never in a log line, never anywhere readable by a process that doesn't need them. The refresh token deserves the most caution of the three, since it usually has the longest lifetime and the most reach if it leaks.
Figure 1 — Token storage has one safe answer per client type. There's no single approach that covers all three.
Whenever the architecture allows it, keep tokens on the server and never let them reach the browser at all. That one decision removes an entire class of attack, since JavaScript running on the page never has anything to steal.
The common pattern is a table keyed by user and provider, encrypted at the column level with keys held somewhere other than the database itself, a KMS or a dedicated secrets manager:
CREATE TABLE oauth_tokens (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL,
provider TEXT NOT NULL,
access_token BYTEA NOT NULL, -- encrypted
refresh_token BYTEA,
expires_at TIMESTAMPTZ,
UNIQUE (user_id, provider)
);Encryption at rest on the database volume is table stakes. Application-level encryption on the token columns specifically is what protects you when the database itself is the thing that gets compromised: a backup exposed, a read replica misconfigured, a query logged somewhere it shouldn't be. Keeping the key separate from the data means a leaked database dump is still useless without it.
The other backend discipline that's easy to skip: tokens never appear in logs, traces, or error messages. A stack trace that happens to print a request object with an Authorization header attached has just put a live credential into whatever log aggregator the team uses, and those tend to have far looser access controls than the database itself.
For a single-page app, what OAuth calls a "public client," there's no place in the browser that's actually safe. Anything JavaScript on the page can read, an attacker's injected script can read too, and localStorage is exactly that: readable by any script running on the page, and it stays there until something explicitly clears it.
The pattern that avoids the problem instead of managing it: keep the access token in memory only, a JavaScript variable or React state that disappears on refresh, and push the refresh token, or a session identifier standing in for it, into an HttpOnly, Secure, SameSite cookie that JavaScript can't read at all. The SPA talks to your backend; your backend reads the cookie, refreshes the token if needed, and makes the actual call to the provider.
Set-Cookie: rt_session=abc123;
HttpOnly;
Secure;
SameSite=Strict;
Path=/api/HttpOnly is the load-bearing flag here. It doesn't stop every attack a compromised page could run, an attacker with script execution can still make requests as the logged-in user, but it stops the specific failure mode of a token walking out through document.cookie or a browser extension's storage scanner.
Native platforms already ship a secure store built for exactly this: Keychain on iOS, Keystore or EncryptedSharedPreferences on Android, and a platform credential store on desktop. Use it instead of a flat file or a bundled database, both of which are readable by anything with access to the device's filesystem, jailbreak or no.
These stores exist because mobile OS vendors treat long-lived credentials as a first-class problem, with hardware-backed encryption on the devices that support it. Reimplementing that yourself, even carefully, tends to end up weaker than what the platform already gives you for free, and we don't think the marginal control is worth the risk.
Two habits matter more than the storage mechanism itself. First, keep access tokens short-lived, minutes to an hour, and lean on refresh tokens for anything that needs to run longer; a short-lived token that leaks is only useful for as long as it's valid, which caps the damage regardless of what else went wrong. Second, rotate refresh tokens wherever the provider supports it: every use returns a new one and invalidates the old, so a stolen refresh token used once tips you off the moment the legitimate client tries to use its own copy and gets rejected.
Deleting your copy is not the same as revoking theirs. Clearing a token from your own database stops your app from using it. It does nothing to whatever session or grant the token represents on the provider's side unless you separately call the provider's revocation endpoint.
That distinction matters most at disconnect. A user who clicks "disconnect" in your app has a reasonable expectation that access actually stops, and the honest way to deliver that is to call the provider's own revocation endpoint as part of the disconnect flow, not just delete the row. We think this is the single most common gap between what a disconnect button implies and what it actually does.
Keep secrets out of source control and CI logs the same way you'd keep a password out of them: no hard-coded tokens, no client secrets pasted into a script for convenience, and a secrets manager or encrypted config for anything that has to be there at deploy time.
localStorage.None of these patterns are specific to any one provider, and none require exotic cryptography. They come down to the same question 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, since nothing sensitive ever reaches a browser. Get the browser case right and the pieces that remain sensitive sit somewhere JavaScript can't touch. Get the mobile case right by using what the OS already built instead of reinventing it.
A token you can't name the storage location for is a token you should assume is already exposed. Storage is not the interesting part of OAuth, and that's exactly why it's where the damage tends to happen.