Cookies vs. Access Tokens: Securing the Authorization Code Flow in Browser-Based Apps

When architecting a Single Page Application (SPA) in frameworks like React, Angular, or Vue, developers inevitably reach a crossroads: Where should I store my access tokens once they are returned to the browser? This architectural decision is one of the most widely debated topics in our developer community.

Storing Tokens in Web Storage (localStorage / sessionStorage)

The Pros: Simple to implement; access tokens are immediately accessible via vanilla JavaScript to construct Authorization: Bearer fetch headers.

The Cons: Extremely Vulnerable to Cross-Site Scripting (XSS). If a single malicious dependency, tracking pixel, or injected script executes on your page, it can access Web Storage, extract your token, and exfiltrate it to an external server.

Storing Tokens in HTTP-Only Cookies

The Pros: Setting the HttpOnly flag on cookie makes it entirely inaccessible to browser-side Javascript, effectively neutralizing standard XSS-based token theft.

The Cons: Introduces vulnerability to Cross-Site Request Forgery (CSRF). If a user visits a malicious site while logged into your app, the browser will automatically append the cookie to a request made to your API. Additionally, managing cookies across cross-domain APIs can be a routing and CORS headache.

The Forum Debate: Developers constantly argue whether cookies really solve all security woes. Read the community thread to understand the nuance of cookie-based risks.

The Best-Practice Resolution: The BFF Pattern and In-Memory JWTs

To balance both security models, modern identity specifications recommend utilizing the Backend-for-Frontend (BFF) pattern or In-Memory JWTs with Secure Session Cookies:

In-Memory Storage: The SPA stores the active access token strictly in a secure, non-global JavaScript variable (e.g., React Context or state). It cannot be read from the DOM and is secure from simple XSS attacks. See the official documentation on tokens vs. cookies best practices.

Session Persistence: Because browser state is cleared on page refresh, the SPA establishes a secure session cookie with the authorization server. This session cookie is a set to HttpOnly, Secure, and SameSite=Strict.

Silent Authentication: When the page is reloaded or the in-memory access token expires, the client uses a hidden iframe or background call to fetch a fresh token silently from the authorization server. Because the browser automatically passes the secure session cookie, the identity provider issues a new token without requiring the user to type their password again.

Hello Christina,

Thanks for sharing this article which is def a widely argued topic at most application dev spaces.

I still have a question (not an argument as I’m not qualified enough for that), If we use BFF pattern, we certainly need a session-cookie set by the backend in the browser so the BFF layer can fetch and pass the right user’s access token while calling APIs for the respective cookie.

How does the risk of setting up an access token as a HTTP-only cookie differ with BFF pattern, esp when it is setting up a similar session cookie?

Hi @karuissobusy ,
The short answer is: While both patterns use cookies, the BFF pattern prevents your raw, highly-privileged APIs and Security Tokens (Access & Refresh Tokens) from ever touching the browser’s memory space.

Key Risk Differences

  1. Blast Radius of Cross-Site Scripting (XSS)
    If an attacker successfully exploits an XSS vulnerability in your frontend (e.g., via a compromised third-party npm package):
  • Without BFF (Direct Token Cookie): If the access token is stored in a cookie, developers often make it readable by JS to inspect user roles, or they store the Refresh Token in local storage. An attacker’s malicious script can exfiltrate the token directly, send it to an offshore server, and abuse your APIs from anywhere in the world until the token expires.
  • With BFF (Opaque Session Cookie): The BFF session cookie is flagged as HttpOnly, meaning JavaScript cannot read it [source: 3]. Because the browser only holds a random session_id pointer, the attacker cannot exfiltrate your Access or Refresh Tokens. They physically do not exist in the browser.

2. The “Proxy” vs. “Direct Access” Security Gate
What can an attacker actually do with what they steal?

• Without BFF: The attacker steals a standard JWT access token. This token is designed to be globally accepted by your APIs. They can use tools like Postman or curl to directly query your backend APIs, bypassing your frontend application entirely, scraping data at high speeds.
• With BFF: Since the attacker only has an HttpOnly cookie, they cannot copy it out of the browser. The only way they can abuse it is by forcing the user’s browser to make requests for them (using Cross-Site Request Forgery or XSS-driven fetch calls).
However, they can only make requests that your BFF backend explicitly permits. If your BFF backend only exposes three frontend-specific routes (e.g., /api/get-my-profile), the attacker cannot hit your wider, privileged microservice APIs (e.g., /api/delete-all-records), even if those backend APIs exist, because the BFF does not expose those routes to the public internet.
3. Token Leakage through Logs & Third Parties

  • Without BFF: Every time your frontend calls your APIs, the browser sends the access token in the request header (Authorization: Bearer <token>). This token is exposed to browser network history, client-side error reporting tools (like Sentry or LogRocket), and intermediate proxy servers.
  • With BFF: The frontend only makes local requests to its own domain. The raw Access and Refresh tokens are securely stored on the server-side (e.g., in a secure Redis cache mapped to the Session ID). The highly-sensitive API scopes and internal routes are never exposed to client-side logging or tracking scripts.

4. The Refresh Token Lifecycle Risk
To keep users logged in, you need a Refresh Token. Storing a refresh token in the browser is highly risky, as stealing it gives an attacker permanent access to the user’s account.

  • Without BFF: You must implement complex client-side mechanics like Refresh Token Rotation (RTR) to detect theft, which is difficult to coordinate securely in single-page apps.
  • With BFF: The refresh token stays safely in your secure backend server’s memory/database. When the session is close to expiring, the BFF server performs a backend-to-backend token swap with Auth0. The browser is never aware a refresh occurred.

You are 100% correct that because both patterns use cookies, they both introduce the risk of CSRF (where a malicious site tricks a user’s browser into sending a request to your backend with the cookie automatically attached).
However, because the BFF uses a standard session cookie, you can apply industry-standard, robust defenses to eliminate CSRF entirely:

  1. SameSite=Strict or Lax flags on your cookies, ensuring they are never sent along with cross-site requests.
  2. Custom Anti-CSRF headers (such as requiring a custom header like X-Requested-With or a double-submit CSRF token), which malicious cross-site scripts cannot easily forge.