Secure Bearer Token Storage in Practice
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:
| Token | Storage | Lifetime |
|---|---|---|
| Access token | In-memory only (encrypted JWT) | 15 minutes |
| Refresh token | HTTP-only encrypted cookie | 7 days |
This approach aligns with OWASP security guidance and is widely used in production SaaS applications.

Photo by Dan Nelson on Pexels.
Architecture Overview
The authentication flow works as follows:
- User logs in
- Server returns an encrypted access token (short-lived JWT) in the response body, and a refresh token (HTTP-only encrypted cookie)
- Frontend stores the access token in memory only — never in
localStorageorsessionStorage - When the access token expires, the frontend silently calls
/refresh - 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 } ◄──┤
│ │
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
- Node.js (Express)
Install Dependencies
npm install express jsonwebtoken cookie-parser cors crypto-js
Server Setup
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.
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);
}
Never hardcode secrets. Always load them from environment variables or a secrets manager. The examples above show the correct pattern.
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
- POST /login
- POST /refresh
- GET /profile
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: "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".
app.post("/refresh", (req, res) => {
const encryptedToken = req.cookies.refreshToken;
if (!encryptedToken) return res.sendStatus(401);
// Validate the Origin header to prevent cross-origin refresh attempts
const allowedOrigin = process.env.ALLOWED_ORIGIN || "http://localhost:3000";
if (req.headers.origin !== allowedOrigin) return res.sendStatus(403);
try {
const token = decryptToken(encryptedToken);
const user = jwt.verify(token, REFRESH_SECRET);
const accessToken = generateAccessToken({ id: user.id });
// Return expiresIn so the frontend can schedule the next silent refresh
res.json({ accessToken, expiresIn: 15 * 60 });
} catch {
res.sendStatus(403);
}
});
app.get("/profile", (req, res) => {
const auth = req.headers.authorization;
if (!auth) return res.sendStatus(401);
try {
const token = decryptToken(auth.split(" ")[1]);
const user = jwt.verify(token, ACCESS_SECRET);
res.json({ id: user.id, email: "dev@example.com" });
} catch {
res.sendStatus(403);
}
});
Frontend Implementation
- JavaScript
The access token lives only in a module-scoped variable. It is never written to any persistent storage.
Auth State
// Module-scoped — not accessible outside this file
let accessToken = null;
let refreshTimer = null;
Login
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.
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
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.
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);
}
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
| Practice | Reason |
|---|---|
| Encrypt all JWTs leaving the server | Prevents token content exposure if intercepted or logged |
| URL-encode encrypted output | CryptoJS Base64 contains characters unsafe in headers and cookies |
| Decrypt on server before verifying signature | Maintains integrity check even with encryption |
| Keep payloads minimal | Limits exposure if decryption were ever compromised |
| Short-lived access tokens (15m) | Limits exposure window if a token is stolen |
| Combine encryption with signing | Encryption provides confidentiality; signing provides integrity |
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.
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.
Cookie Flags
| Flag | Value | Effect |
|---|---|---|
httpOnly | true | Inaccessible to JavaScript — XSS-proof |
secure | true | Only sent over HTTPS |
sameSite | strict | Never sent in cross-site requests — CSRF protection |
path | /refresh | Scoped to a single endpoint — minimises exposure |
Access Token vs Bearer Token
- Access token — what the token is for (authorizing API access)
- Bearer token — how 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
- 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
Originheader on the/refreshendpoint - Implement logout that clears the
refreshTokencookie 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.
HTTP-only Cookie
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.
SameSite Cookie Policy
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.
