Skip to content

JWT Token Storage — Secure Client-Side Storage for Access and Refresh Tokens

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you will learn about JWT Token Storage. We cover key concepts, practical examples, and best practices to help you master this topic.

JWT token storage on the client must balance accessibility (tokens must be available for API calls) and security (tokens must be protected from theft).

What You'll Learn

The pros and cons of each storage location, how to choose the right strategy, and security considerations for web and mobile applications.

Why It Matters

Token storage is the most common JWT vulnerability. Storing tokens in localStorage makes them accessible to any JavaScript on the page (XSS vulnerability). Choosing the wrong strategy compromises all other security measures.

Real-World Use

Auth0 recommends storing tokens in memory for SPAs. Firebase stores ID tokens in memory and refreshes via httpOnly cookies. GitHub uses httpOnly cookies for its web session.

flowchart TD
    A["Token Storage"] --> B["Web App"]
    A --> C["Mobile App"]
    B --> D["httpOnly Cookie\nBest for security"]
    B --> E["In-Memory\nGood for SPAs"]
    B --> F["localStorage\nAvoid if possible"]
    C --> G["Keychain (iOS)\nor Keystore (Android)"]
    style A fill:#dbeafe,stroke:#2563eb
    style D fill:#dcfce7,stroke:#16a34a
    style E fill:#fef3c7,stroke:#d97706
    style F fill:#fecaca,stroke:#dc2626
    style G fill:#dcfce7,stroke:#16a34a

Storage Comparison

Storage XSS Protection Persistence Complexity
httpOnly Cookie Excellent (JS cannot access) Browser session or expiry Low
In-Memory (variable) Good (XSS access limited) Lost on page refresh Medium
localStorage None (JS can read) Permanent Low
sessionStorage None (JS can read) Tab session Low
Web Worker Good (isolated) Lost on worker restart High
from flask import Flask, make_response, jsonify, request
import jwt, datetime

app = Flask(__name__)
SECRET = "your-secret"

@app.route("/api/auth/login", methods=["POST"])
def login():
    data = request.get_json()
    # Validate credentials (simplified)
    if data.get("password") != "correct":
        return jsonify({"error": "Invalid credentials"}), 401

    token = jwt.encode({
        "sub": data["username"],
        "exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=15)
    }, SECRET, algorithm="HS256")

    # Set access token as httpOnly cookie
    response = make_response(jsonify({"message": "Logged in"}))
    response.set_cookie(
        "access_token", token,
        httponly=True,     # JavaScript cannot read
        secure=True,       # HTTPS only
        samesite="Lax",    # CSRF protection
        max_age=900        # 15 minutes
    )
    return response

@app.route("/api/profile")
def profile():
    token = request.cookies.get("access_token")
    if not token:
        return jsonify({"error": "Not authenticated"}), 401
    try:
        payload = jwt.decode(token, SECRET, algorithms=["HS256"])
        return jsonify({"user": payload["sub"]})
    except jwt.InvalidTokenError:
        return jsonify({"error": "Invalid token"}), 401

Code Example: In-Memory Storage for SPAs

// Memory-based token storage — survives page navigation but not refresh
const tokenStore = (() => {
  let accessToken = null;
  let refreshToken = null;

  return {
    setTokens(access, refresh) {
      accessToken = access;
      refreshToken = refresh;
    },
    getAccessToken() {
      return accessToken;
    },
    getRefreshToken() {
      return refreshToken;
    },
    clear() {
      accessToken = null;
      refreshToken = null;
    }
  };
})();

// Usage
async function login() {
  const response = await fetch('/api/auth/login', { method: 'POST', ... });
  const data = await response.json();
  tokenStore.setTokens(data.access_token, data.refresh_token);
}

async function apiCall() {
  let token = tokenStore.getAccessToken();

  // Refresh if needed (check expiry)
  if (isTokenExpired(token) && tokenStore.getRefreshToken()) {
    const refreshResponse = await fetch('/api/auth/refresh', {
      method: 'POST',
      headers: { 'X-Refresh-Token': tokenStore.getRefreshToken() }
    });
    const data = await refreshResponse.json();
    tokenStore.setTokens(data.access_token, data.refresh_token);
    token = data.access_token;
  }

  return fetch('/api/data', {
    headers: { 'Authorization': `Bearer ${token}` }
  });
}

Common Mistakes

1. Storing Tokens in localStorage

localStorage is accessible to any JavaScript running on the page. A single XSS vulnerability exposes all tokens.

2. Storing Refresh Tokens with Access Tokens

If access tokens are in memory but refresh tokens are in localStorage, the refresh token is vulnerable even though the access token is not.

3. Not Clearing Tokens on Logout

When the user logs out, tokens in memory or variables must be cleared. httpOnly cookies can be cleared server-side.

4. Using sessionStorage for Refresh Tokens

sessionStorage is cleared when the tab closes. This logs the user out unexpectedly. Use httpOnly cookies for persistence.

5. Storing Sensitive Data in JWTs When Using localStorage

If you must use localStorage, at least ensure the JWT contains no sensitive data. But prefer httpOnly cookies.

Practice Questions

  1. Why is localStorage considered insecure for token storage?
  2. What is the most secure token storage strategy for web apps?
  3. How does an httpOnly cookie protect against XSS?
  4. Why do SPAs typically store tokens in memory?
  5. How should mobile apps store tokens?

Answers:

  1. Any JavaScript running on the page (including XSS-injected scripts) can read localStorage via localStorage.getItem().
  2. httpOnly cookies with Secure and SameSite flags. JavaScript cannot access them, and they are sent automatically with requests.
  3. The httpOnly flag prevents JavaScript from reading the cookie via document.cookie. Even if XSS executes, the attacker cannot access the token.
  4. In-memory tokens are not accessible to JavaScript from other origins and are lost on page refresh (defense against persistent XSS).
  5. iOS Keychain or Android Keystore — encrypted, device-specific, and application-isolated storage.

Challenge: Implement a token storage strategy for an SPA that uses httpOnly cookies for access tokens and in-memory storage for refresh tokens. Include silent refresh on page load using a dedicated refresh endpoint.

FAQ

Can I use both httpOnly cookies and localStorage?

Yes. Store the access token in an httpOnly cookie (automatic with requests) and the refresh token in memory (more secure).

What is a BFF pattern?

Backend for Frontend — a lightweight proxy that handles token storage on the server. The SPA never sees tokens; the BFF manages them via httpOnly cookies.

How do mobile apps handle token refresh on restart?

Store the refresh token in secure device storage. On app start, use it to get a new access token. If refresh fails, show the login screen.

Is sessionStorage more secure than localStorage?

Slightly — it is cleared when the tab closes. But both are accessible to JavaScript. Neither is secure against XSS.

What happens to httpOnly cookies in cross-origin requests?

By default, cookies are not sent with cross-origin requests. Use withCredentials: true (Fetch) and proper CORS configuration.

Mini Project

Implement a Flask + JavaScript SPA with httpOnly cookie-based token storage. The login sets the token as an httpOnly Secure SameSite cookie. All API calls use cookies automatically. The SPA never touches the token directly.

What's Next

Now learn about JWT Blacklisting — how to revoke JWTs when short expiry is not enough.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro