vM.

Access and Refresh Tokens: Where to Store Them in a Browser App

Author
Vishal Maurya
Published on
Reading time
4 min read

Overview

You have an API that issues an access token and a refresh token, and now the frontend needs to keep them somewhere. localStorage is easy but feels risky. Cookies feel safer but raise CSRF questions. Which tradeoff should you accept?

This post explains the two threats involved and a conservative storage pattern for browser apps.

1. The Two Threats: XSS and CSRF

Cross-site scripting (XSS) lets an attacker run JavaScript in your page. Anything JavaScript can read, including localStorage, sessionStorage, and non-HttpOnly cookies, can be stolen and replayed from anywhere.

Cross-site request forgery (CSRF) tricks a victim's browser into sending an authenticated request, because browsers attach cookies automatically. Tokens that you attach manually in an Authorization header are not sent automatically, so they are not subject to classic CSRF.

So the choice is not "safe versus unsafe". Each storage option trades one risk for another, and you can mitigate CSRF with well-understood controls more reliably than you can guarantee your app has no XSS.

2. Why HttpOnly Cookies Are the Conservative Default

An HttpOnly cookie cannot be read by JavaScript. An XSS bug can still make requests as the user while the page is open, but it cannot exfiltrate the token for use later from another machine. That reduces the blast radius.

Combine it with:

  • Secure, so it is only sent over HTTPS.
  • SameSite=Lax or Strict, which blocks the cookie on most cross-site requests.
  • A narrow Path for the refresh cookie, so it is only sent to the refresh endpoint.

If your frontend and API are on different sites, SameSite behavior gets more complicated. Prefer serving both under the same registrable domain, and check current browser behavior before relying on cross-site cookies.

3. Keep Access Tokens Short-Lived

An access token should live for minutes, not days. If it leaks, the damage window is small. The refresh token is the longer-lived credential, so it deserves the stricter storage and handling.

A common arrangement is both tokens in HttpOnly cookies, with the refresh cookie scoped to the refresh path:

from fastapi import Response


def set_auth_cookies(response: Response, access: str, refresh: str) -> None:
    response.set_cookie(
        "access_token",
        access,
        max_age=15 * 60,
        httponly=True,
        secure=True,
        samesite="lax",
        path="/",
    )
    response.set_cookie(
        "refresh_token",
        refresh,
        max_age=14 * 24 * 60 * 60,
        httponly=True,
        secure=True,
        samesite="strict",
        path="/auth/refresh",
    )

The lifetimes above are examples. Choose values that match your risk tolerance and user experience.

4. Rotate Refresh Tokens

Treat each refresh token as single use. When the client calls the refresh endpoint, issue a new access token and a new refresh token, and invalidate the old one. Store a hash of the current refresh token server-side, tied to a session or device record.

If an already-used refresh token shows up again, that suggests theft or a replay. A common response is to revoke the whole token family for that session and require a fresh login. This is the approach described in OAuth security guidance for public clients, so check the current OAuth 2.0 Security Best Current Practice for details.

Because refresh tokens are tracked in your database, you also get a clean logout and a place to list active devices.

5. Add CSRF Protection Where Cookies Authenticate

When a cookie authenticates a request, add CSRF defenses for state-changing endpoints. Options include:

  • SameSite cookies as a baseline layer.
  • A synchronizer token or a signed double-submit token sent in a custom header.
  • Verifying the Origin header on unsafe methods.
  • Requiring a custom header that cross-site forms cannot set.

Do not use GET requests for state changes, since SameSite=Lax still permits cookies on top-level navigations that use GET.

6. Reduce XSS Exposure Anyway

Cookie storage does not excuse XSS. Use a strict Content Security Policy, avoid injecting untrusted HTML, rely on your framework's output escaping, and audit third-party scripts. Storing tokens out of reach of JavaScript is defense in depth, not a replacement for these.

7. What About Native and Single-Page Apps Using a Backend?

If you have a single-page app, consider a backend-for-frontend pattern: a small server session holds the tokens and the browser only has a session cookie. This keeps third-party tokens away from the browser entirely. Check the current IETF guidance on OAuth for browser-based apps before choosing a design.

Conclusion

For browser apps, keep access tokens short-lived, store tokens in HttpOnly, Secure, SameSite cookies, scope and rotate the refresh token, and add CSRF protection for cookie-authenticated writes. Avoid localStorage for long-lived credentials.

If you want a second pair of eyes on your token flow or cookie configuration, I can help review and harden it. Contact me.

Additional Resources

  • OWASP Cross-Site Request Forgery Prevention Cheat Sheet
  • OWASP HTML5 Security Cheat Sheet
  • RFC 6749: The OAuth 2.0 Authorization Framework