Skip to main content

Secure Bearer Token Storage in Practice

· 10 min read
Mohamed El Amine Meghni
Mohamed El Amine Meghni
Software & DevOps Engineer

For developers at Sadeem informatique

Most articles explain where to store tokens. This guide shows you exactly how to implement a secure setup in a real web application.

We'll build the modern recommended pattern:

TokenStorageLifetime
Access tokenIn-memory only (encrypted JWT)15 minutes
Refresh tokenHTTP-only encrypted cookie7 days

This approach aligns with OWASP security guidance and is widely used in production SaaS applications.

Laptop displaying a lock icon representing secure token and authentication practices

Photo by Dan Nelson on Pexels.

Architecture Overview

The authentication flow works as follows:

  1. User logs in
  2. Server returns an encrypted access token (short-lived JWT) in the response body, and a refresh token (HTTP-only encrypted cookie)
  3. Frontend stores the access token in memory only — never in localStorage or sessionStorage
  4. When the access token expires, the frontend silently calls /refresh
  5. Server decrypts and validates the refresh token cookie, then issues a new access token
Browser                          Server
│ │
│ POST /login │
├───────────────────────────────►│
│ │
│ { accessToken, expiresIn } ◄──┤ Set-Cookie: refreshToken (httpOnly)
│ │
│ accessToken → stored in memory│
│ │
│ GET /profile │
│ Authorization: Bearer <token> │
├───────────────────────────────►│
│ │ decrypt → verify → respond
│ { profile data } ◄────────────┤
│ │
│ [token expires] │
│ │
│ POST /refresh (cookie auto-sent)
├───────────────────────────────►│
│ { accessToken, expiresIn } ◄──┤
│ │
No localStorage, ever

Storing tokens in localStorage or sessionStorage exposes them to XSS attacks. Any injected script can read them. In-memory storage means the token disappears on page reload and is invisible to scripts.

Backend Implementation

Install Dependencies

npm install express jsonwebtoken cookie-parser cors crypto-js

Server Setup

server.js
const express = require("express");
const jwt = require("jsonwebtoken");
const cookieParser = require("cookie-parser");
const cors = require("cors");
const CryptoJS = require("crypto-js");

const app = express();
app.use(express.json());
app.use(cookieParser());

app.use(cors({
origin: "http://localhost:3000",
credentials: true, // required for cross-origin cookies
}));

Token Helpers

All tokens are encrypted before leaving the server. The server decrypts them on receipt before verifying the JWT signature.

The encrypted output is URL-encoded to ensure it is safe for use in Authorization headers and cookies — CryptoJS AES produces Base64 output that contains +, /, and = characters which can cause parsing issues without encoding.

tokens.js
const ACCESS_SECRET = process.env.ACCESS_SECRET;
const REFRESH_SECRET = process.env.REFRESH_SECRET;
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY;

function generateAccessToken(user) {
const token = jwt.sign(user, ACCESS_SECRET, { expiresIn: "15m" });
// URL-encode the Base64 output to make it safe for headers and cookies
return encodeURIComponent(CryptoJS.AES.encrypt(token, ENCRYPTION_KEY).toString());
}

function generateRefreshToken(user) {
const token = jwt.sign(user, REFRESH_SECRET, { expiresIn: "7d" });
return encodeURIComponent(CryptoJS.AES.encrypt(token, ENCRYPTION_KEY).toString());
}

function decryptToken(encryptedToken) {
// Decode before decrypting
const bytes = CryptoJS.AES.decrypt(decodeURIComponent(encryptedToken), ENCRYPTION_KEY);
return bytes.toString(CryptoJS.enc.Utf8);
}
Use environment variables

Never hardcode secrets. Always load them from environment variables or a secrets manager. The examples above show the correct pattern.

Why not JWE (RFC 7516)?

This guide uses a custom AES envelope (CryptoJS + JWT) rather than the JWE standard. Both achieve encrypted tokens — the difference is interoperability. If you need standard JWE, use the jose library instead.

Routes

routes/login.js
app.post("/login", (req, res) => {
// Replace with real user lookup + password verification
const user = { id: 1, email: "dev@example.com" };

const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);

res.cookie("refreshToken", refreshToken, {
httpOnly: true, // not accessible via JavaScript
secure: true, // HTTPS only
sameSite: "strict", // no cross-site sending (see note below)
path: "/refresh", // scoped to the refresh endpoint only
});

// Return expiresIn so the frontend can schedule a proactive refresh
res.json({ accessToken, expiresIn: 15 * 60 });
});
sameSite and OAuth/SSO

sameSite: "strict" drops the cookie on cross-site redirects, which will break OAuth or SSO flows (e.g. "Sign in with Google"). If you add a third-party identity provider later, change this to "lax".

Frontend Implementation

The access token lives only in a module-scoped variable. It is never written to any persistent storage.

Auth State

auth.js
// Module-scoped — not accessible outside this file
let accessToken = null;
let refreshTimer = null;

Login

auth.js
export async function login() {
const res = await fetch("http://localhost:4000/login", {
method: "POST",
credentials: "include", // sends/receives cookies
});

const data = await res.json();
accessToken = data.accessToken;

// Schedule a proactive refresh before the token expires
scheduleRefresh(data.expiresIn);
}

Fetch Wrapper with Auto-Refresh

This wrapper transparently handles token expiry. If a request gets a 401 or 403, it silently refreshes the access token and retries once.

auth.js
export async function apiFetch(url, options = {}) {
options.headers = {
...options.headers,
Authorization: `Bearer ${accessToken}`,
};

let res = await fetch(url, { ...options, credentials: "include" });

if (res.status === 401 || res.status === 403) {
await refreshAccessToken(); // try to get a new access token
options.headers.Authorization = `Bearer ${accessToken}`;
res = await fetch(url, { ...options, credentials: "include" });
}

return res;
}

Silent Refresh

auth.js
async function refreshAccessToken() {
const res = await fetch("http://localhost:4000/refresh", {
method: "POST",
credentials: "include", // the httpOnly cookie is sent automatically
});

if (!res.ok) throw new Error("Session expired. Please log in again.");

const data = await res.json();
accessToken = data.accessToken;

// Reschedule the next proactive refresh
scheduleRefresh(data.expiresIn);
}

Proactive Refresh Scheduling

Since the access token is encrypted, the frontend cannot parse its exp claim directly. Instead, the server returns expiresIn (in seconds) alongside the token, which the frontend uses to schedule a refresh ~1 minute before expiry.

auth.js
function scheduleRefresh(expiresInSeconds) {
if (refreshTimer) clearTimeout(refreshTimer);

// Refresh 60 seconds before expiry to avoid any clock drift
const delay = (expiresInSeconds - 60) * 1000;

refreshTimer = setTimeout(async () => {
try {
await refreshAccessToken();
} catch {
// Token could not be refreshed — force the user to log in again
accessToken = null;
}
}, delay);
}
Why not parse exp from the token?

Because the access token is encrypted, its payload is opaque to the frontend. Using the server-returned expiresIn value is the correct approach here. If you were using plain (non-encrypted) JWTs, you could decode the exp claim directly with JSON.parse(atob(token.split('.')[1])).

Security Practices

Encrypted JWTs

PracticeReason
Encrypt all JWTs leaving the serverPrevents token content exposure if intercepted or logged
URL-encode encrypted outputCryptoJS Base64 contains characters unsafe in headers and cookies
Decrypt on server before verifying signatureMaintains integrity check even with encryption
Keep payloads minimalLimits exposure if decryption were ever compromised
Short-lived access tokens (15m)Limits exposure window if a token is stolen
Combine encryption with signingEncryption provides confidentiality; signing provides integrity
Use cryptographically secure algorithms

Always use modern, vetted cryptographic algorithms and libraries. Good examples include AES-256-GCM for authenticated encryption, ChaCha20-Poly1305 for AEAD, Ed25519 for signatures, and SHA-256/SHA-3 for hashing.

Also review your crypto choices regularly and verify they have not been deprecated or broken by new research, standards updates, or compliance requirements.

Broken crypto alert

Do not use broken or weak algorithms such as DES, RC4, MD5, or SHA-1 for security-critical use.
Example: SHA-1 is considered broken for collision resistance and should not be used for signatures or integrity protection in new systems.

FlagValueEffect
httpOnlytrueInaccessible to JavaScript — XSS-proof
securetrueOnly sent over HTTPS
sameSitestrictNever sent in cross-site requests — CSRF protection
path/refreshScoped to a single endpoint — minimises exposure

Access Token vs Bearer Token

  • Access tokenwhat the token is for (authorizing API access)
  • Bearer tokenhow the token is used (anyone who holds it can use it)

In standard REST APIs, the access token travels as a bearer token in the Authorization header. With encrypted JWTs, the bearer concept still applies — the server decrypts and verifies on each request.

Production Hardening

Before you ship
  • Rotate refresh tokens on every use (one-time-use tokens)
  • Store refresh tokens server-side (DB or cache) for revocation support
  • Enforce HTTPS everywhere — never allow HTTP in production
  • Validate Origin header on the /refresh endpoint
  • Implement logout that clears the refreshToken cookie server-side
  • Add a Content Security Policy header to defend against XSS
  • Load all secrets from environment variables — never commit them
  • Switch to sameSite: "lax" if you add OAuth/SSO providers

Glossary

Access Token

A short-lived credential sent with each API request to prove the user is authenticated. Typically valid for 15 minutes.

Bearer Token

A token authentication scheme where any party possessing the token can use it. Access tokens in web apps are bearer tokens by convention.

Refresh Token

A long-lived credential used exclusively to obtain new access tokens, without requiring the user to log in again.

A browser cookie that cannot be read or written by JavaScript. Protects the refresh token from XSS-based theft.

CSRF (Cross-Site Request Forgery)

An attack in which a malicious site tricks a user's browser into sending authenticated requests to another site. Mitigated here by sameSite: strict and scoping the refresh cookie to path: /refresh.

XSS (Cross-Site Scripting)

A vulnerability allowing attackers to inject malicious JavaScript into your page. Keeping tokens out of localStorage/sessionStorage means XSS cannot steal them.

JWT (JSON Web Token)

A signed token format used to transmit identity and authorization claims between parties.

Encrypted JWT (Custom Envelope)

A JWT whose content is encrypted using AES before transmission, so only the server can read the payload. This guide uses a custom CryptoJS + JWT envelope — not the JWE standard (RFC 7516). For standards-compliant encrypted JWTs, use the jose library.

A browser attribute controlling whether cookies are sent in cross-site requests. strict means the cookie is only sent when the request originates from the same site. Note: strict is incompatible with OAuth/SSO redirect flows — use lax in those cases.

CSP (Content Security Policy)

An HTTP header that restricts which scripts and resources the browser is permitted to load, reducing the impact of XSS attacks.

Conclusion

Secure bearer-token handling is not about a single storage choice, but about a full lifecycle: short-lived access tokens in memory, refresh tokens in HTTP-only cookies, strict server validation, and proactive refresh on the client. When combined with HTTPS, origin checks, token rotation, and CSP, this pattern provides a practical and production-ready baseline for modern web authentication.