Est.

OAuth 2.0 Token Storage Security for SaaS Applications

RFC 9700 overhauls token storage requirements for SaaS apps built before 2025.

Contributing Editor · · 10 min read
Cover illustration for “OAuth 2.0 Token Storage Security for SaaS Applications”
Integration Security · September 10, 2026 · 10 min read · 2,243 words

RFC 9700 landed in January 2025, and it changes how OAuth 2.0 tokens get stored, rotated, and tied to the client that requested them. If your SaaS app's auth flow was built before that date, some part of it is probably out of step with what's now considered baseline practice.

The IETF calls it "Best Current Practice for OAuth 2.0 Security," or BCP 240 if you want the filing-cabinet name. It replaces the guidance that used to be scattered across RFC 6749, 6750, and 6819, and it isn't a tidy-up exercise. It's a document written by people who watched OAuth get abused in the wild for over a decade and decided to write down what actually works.

Three things got killed outright. The Implicit grant is gone. Resource Owner Password Credentials is gone. Both were common in older single-page apps and legacy SaaS integrations, and both are now flagged as unacceptable rather than merely discouraged. In their place: PKCE is mandatory for authorization code flows, redirect URIs need exact string matching at the authorization server, mix-up attack prevention is required when a client talks to more than one authorization server, and sender-constrained tokens (DPoP or mTLS, more on those shortly) get an explicit recommendation. Pushed Authorization Requests are the preferred pattern for anything high-security. None of this is a side conversation. It's getting folded straight into OAuth 2.1, so this is the direction, not a detour.

The breach environment that made these rules urgent

August 2025 gave the industry a case study it didn't ask for. Attackers used compromised OAuth tokens tied to Salesloft's Drift integration to pull data out of Salesforce environments belonging to more than 700 organizations. Researchers at Obsidian Security noted the blast radius was roughly ten times larger than previous incidents.

Worth sitting with that for a second: the attackers didn't touch Salesforce's login page. They didn't need to. They compromised a third-party app that already had a trusted token, and that token didn't care about the SSO policy or the MFA prompt sitting on the front door. Refresh tokens operate on their own track, and once one's stolen, it just looks like normal app traffic.

That's the mechanical reason this keeps happening. Refresh tokens are bearer credentials, they can live for days or weeks, and per Obsidian Security, most SaaS applications don't automatically kill them when a user resets a password or updates MFA. The token doesn't know the password changed. It just keeps working.

Zoom out and the pattern isn't isolated. Obsidian Security's data shows SaaS breaches climbing 300% between September 2023 and 2024, and API attacks up 680% between 2021 and 2025. The average data breach now costs $4.88 million, which is the number worth keeping in your head while reading the rest of this piece, because every storage decision below is cheaper than that. Add to it: The State of Secrets Sprawl 2026 counted 24,008 unique secrets exposed in MCP configuration files in 2025 alone, and AI-related credential leaks jumped 81.5% year over year. No OWASP report puts an exact percentage on how much of this traces back to bad token storage specifically, but it's consistently named as a leading cause of token theft in web apps. The threat landscape is why RFC 9700 gets so specific about storage and rotation. What follows is how to actually build to it.

How the two token types differ in risk profile and why that shapes every storage decision

Access tokens and refresh tokens are not the same animal, and treating them the same is where a lot of this goes wrong.

Access tokens are short-lived and scoped. Best practice now (this part comes from general JWT guidance, not RFC 9700 itself) puts their lifespan at 5 to 15 minutes for high-risk APIs, maybe 30 to 60 minutes for everyday use, signed with a strong asymmetric algorithm. Short life span means a narrow window: steal one of these and you get a few minutes of access before it's dead weight. As of 2024, 92% of web apps use OAuth 2.0 somewhere in their login or API authorization stack, so this isn't a niche concern. It's the default plumbing of the modern web.

Refresh tokens are the long game. They stick around for days to months depending on how they're configured, and they mint new access tokens without asking the user to log in again. That's convenient right up until it's the exact mechanism an attacker rides. They run independently of SSO and MFA entirely, which means once one's compromised, the attacker's requests look identical to the real app doing its job. They're also the connective tissue in SaaS-to-SaaS integrations, the quiet bridges attackers walk across to get from one system to the next.

The asymmetry, stated plainly: a stolen access token is a timer running out. A stolen refresh token is a standing invitation. RFC 9700 addresses this directly, requiring that refresh tokens issued to public clients either be sender-constrained or use rotation. Confidential clients get handled under separate client-authentication rules. That split is exactly what shapes the storage decisions in the next section.

Diagram: A Stolen Token's Timeline: Access vs. Refresh. Visualizes: Visualize the asymmetric risk between access tokens and refresh tokens as a contrast in lifetime and consequence.

The three browser storage options and their actual risk tradeoffs

localStorage / sessionStorage. Anything JavaScript running on the page can read these, full stop. Security guidance is consistent on this point: don't put session identifiers in local storage, because the data is always reachable by script. Worth naming a common mix-up here too: sessionStorage isn't meaningfully more secure than localStorage against this, it just clears when the tab closes. Same access model, same exposure.

Here's the failure mode: one XSS bug, whether from a compromised third-party script, stored XSS sitting in a comment field, or a CSP with a hole in it, hands an attacker every token in storage. All of them. At once. To be fair, localStorage doesn't carry CSRF risk since an outside site can't read it remotely. But XSS is the one door that matters, and it's a wide one. Verdict: fine for non-sensitive, very short-lived access tokens behind a tight CSP, never acceptable for refresh tokens.

HttpOnly cookies. The HttpOnly flag blocks JavaScript from reading the cookie's contents, so XSS can't just scoop the value out. Current guidance wants the full stack: HttpOnly, Secure, and SameSite=Strict (or Lax, if you're making a deliberate tradeoff for usability). Current security guidance backs this up: HTTP-only and Secure cookies stop JavaScript access and shrink the XSS surface.

But cookies bring their own problem, CSRF, and HttpOnly alone doesn't touch it. SameSite and Secure are the actual mitigation there, not bonus hardening you can skip. A cookie missing SameSite isn't a safer version of localStorage, it's a different vulnerability wearing a nicer suit. Verdict: a real improvement over localStorage for refresh tokens, but only with every attribute in place.

In-memory access token plus HttpOnly cookie for the refresh token. This is where things land for a reason. The access token lives in JavaScript memory only, never touches disk, and disappears the moment the tab closes or the page reloads. The refresh token sits in an HttpOnly, Secure, SameSite=Lax cookie. When the access token expires or the page loads fresh, a silent call to a /refresh endpoint fetches a new one using the cookie, and the refresh token itself is never exposed to JavaScript at any point in the process.

What this buys: the attack surface shrinks from "any XSS anywhere on the site steals the token" down to "the attacker has to specifically exploit the refresh endpoint." That's a much smaller target to hit. It aligns with RFC 9700's broader guidance for browser-based apps using Authorization Code Flow with PKCE. The tradeoff: the access token disappears on a page refresh, so the app needs to silently re-fetch it. That's expected behavior, not a bug to file a ticket against. For SPAs and browser-based SaaS apps, this is the pattern to build toward.

Diagram: Browser Storage Options: Risk at a Glance. Visualizes: Show a ranked or stepped comparison of the three browser storage patterns for tokens, moving from weakest to strongest: (1) localStorage/sessionStorage — full JavaScript access, XSS…

Refresh token rotation: the RFC 9700 requirement and how to implement it without creating a denial-of-service on your own users

RFC 9700 doesn't hedge on this one. Refresh tokens for public clients need to be sender-constrained or rotated, and that's a compliance line, not a suggestion to think about over coffee.

Rotation itself is simple to describe: every time a refresh token gets used, the server issues a new one and kills the old one. A stolen token that gets intercepted now has a shelf life measured by "until the legitimate client tries to use it next," not days or weeks of free access.

Say an attacker uses a stolen refresh token first. The legitimate client tries next, presents what it thinks is a valid token, and gets rejected, because the server already invalidated it. The server should treat that specific pattern (a token being reused after rotation) as a signal that the whole token family is blown, and invalidate all of it, not just the one token that got replayed. Building this means the backend keeps a record of used tokens to catch replay attempts. Skip that record-keeping and rotation is only half-built.

There's a server-side storage requirement too: refresh tokens should be encrypted or hashed at rest, never sitting in a database as plain text. Current security guidance says the same thing about both access and refresh tokens in storage. A database breach shouldn't hand over working credentials on a plate. And refresh tokens are best kept server-side rather than persisted in client-side storage.

Lifetime is the last dial. Obsidian Security's guidance suggests 7 to 30 days for sensitive apps, capped at 24 hours for SPAs. Shorter windows shrink exposure, but a token that quietly rotates isn't a replacement for watching for anomalies. It's one layer, not the whole wall. Rotation shrinks the window a stolen token stays useful. It doesn't stop the theft from happening at the storage layer in the first place, which is the next problem.

Why bearer tokens are structurally weak and what sender-constraining actually does

Here's the structural flaw baked into bearer tokens: whoever's holding the string gets the access. The server has no way to tell the difference between the client it issued the token to and an attacker who just copied the value somewhere else.

And there are more ways to copy that value than people expect: a compromised SPA, a malicious browser extension sitting quietly in someone's Chrome profile, a log line that captured a token value it shouldn't have, a TLS-terminating proxy in the middle of the request path. Good storage helps, but none of it changes the underlying fact that the token, by itself, is the whole credential.

Sender-constraining fixes the actual design flaw instead of patching around it. It ties the token to cryptographic material the client holds, so possessing the token string alone isn't enough. The client has to prove, on every single use, that it holds the matching key.

This isn't the first attempt at the idea. Google pulled Token Binding support out of Chrome entirely back in 2018, in Chrome 70. That was a TLS-layer approach to the same problem, and it never got enough of the ecosystem to adopt it. DPoP works at the application layer instead, which means it doesn't need browsers or TLS infrastructure to cooperate the way the older approach did. Bearer tokens remain the default trust model in a lot of environments today, but that model falls apart the instant a token can be copied, intercepted, or replayed somewhere the client never intended.

DPoP and mTLS as the two RFC 9700-endorsed sender-constraining mechanisms

Two mechanisms carry RFC 9700's endorsement, and they're built for different situations.

DPoP (Demonstrating Proof-of-Possession, standardized as RFC 9449 in September 2023) ties access and refresh tokens to a public/private key pair the client holds. The client signs a fresh proof, a JWT, for every token request and every resource request. Not once at login. Every time. It's built for public clients, meaning SPAs and mobile apps, and adoption has moved fast since 2023. Library support already exists: Spring Security, the Nimbus OAuth 2.0 SDK, oidc-client-ts, requests_oauth2client. Some platforms have already moved to requiring DPoP on every request rather than treating it as optional, pairing it with server-issued nonces, PAR, and PKCE. The OpenID Foundation's FAPI 2.0 Security Profile accepts DPoP alongside mTLS for high-value APIs in finance, e-health, and government use, noting mTLS as preferred in some of those scenarios.

mTLS (RFC 8705) binds tokens to a client certificate at the TLS layer, so the token only works if it shows up with the matching certificate attached. It fits confidential clients better, think server-to-server integrations where there's infrastructure control on both ends. For confidential clients, mTLS is often favored over DPoP given the extra binding at the TLS channel. FAPI 2.0 permits either mTLS or DPoP, and the choice mostly comes down to deployment context, though the spec flags mTLS as offering stronger replay protection in some cases.

Obsidian Security's read on enterprise adoption heading into 2026: organizations are rolling out DPoP first for their highest-risk integrations, with wider adoption planned as client library support keeps maturing. RFC 9700 frames sender-constraining as a recommendation where feasible, but for public clients specifically, the requirement is sender-constraining or rotation, and sender-constraining is the stronger of the two.

The decision rule, boiled down: building an SPA or mobile app, DPoP is the road to take, and the libraries are ready today. Running server-to-server with a confidential client, mTLS gives you the TLS-layer binding that fits that setup better.

More in Integration Security