Skip to main content

CORS, CSRF, and Token Security Best Practices

Cross-Origin Resource Sharing (CORS) and Cross-Site Request Forgery (CSRF) are two distinct security concerns that often intersect in token-based authentication. CORS controls which external websites can make requests to your API; CSRF prevents attackers from forging requests on behalf of authenticated users. This article covers CORS configuration for token-based auth, CSRF protection strategies, and defense-in-depth practices for React apps.

Understanding CORS and CSRF​

CORS: Control Cross-Origin API Access​

CORS is a browser security mechanism that prevents JavaScript from making requests to a different domain without explicit permission. For example, if your React app is at app.example.com and your backend API is at api.example.com, CORS is triggered.

When a request crosses origins, the browser sends an Origin header and waits for an Access-Control-Allow-Origin response header. If the server approves, the request proceeds; otherwise, the browser blocks it.

CSRF: Prevent Forged Requests​

CSRF (Cross-Site Request Forgery) is an attack where a malicious website tricks your browser into making a request to your bank or app on your behalf. For example:

  1. You log in to your bank at bank.example.com
  2. You visit a malicious site attacker.com in another tab
  3. The malicious site contains a hidden form that submits to bank.example.com/transfer?amount=1000&to=attacker
  4. Your browser automatically includes your bank cookies (because cookies are sent automatically for same-domain requests)
  5. The bank transfers money without your knowledge

CSRF protection ensures requests come from your own app, not a third-party site.

CORS Configuration for Token-Based Auth​

Backend: Express with CORS​

// backend/server.js (Node.js/Express)
const cors = require('cors');
const express = require('express');
const cookieParser = require('cookie-parser');

const app = express();

// Configure CORS for token-based auth
const corsOptions = {
origin: process.env.FRONTEND_URL || 'https://app.example.com', // Exact frontend domain
credentials: true, // Allow cookies and credentials
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 3600 // Cache preflight response for 1 hour
};

app.use(cors(corsOptions));
app.use(cookieParser());
app.use(express.json());

// Routes
app.post('/api/login', (req, res) => {
// ... login logic
});

app.get('/api/me', (req, res) => {
// ... protected endpoint
});

Key CORS Headers Explained​

HeaderPurposeExample
OriginSent by browser; origin of the requestOrigin: https://app.example.com
Access-Control-Allow-OriginServer response; allowed originAccess-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-CredentialsAllow cookies in CORS requestsAccess-Control-Allow-Credentials: true
Access-Control-Allow-MethodsAllowed HTTP methodsAccess-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-HeadersAllowed request headersAccess-Control-Allow-Headers: Content-Type, Authorization

React: Sending Credentials with CORS​

// React app at app.example.com calling api.example.com

// ❌ Wrong: Does not send cookies
const response = await fetch('https://api.example.com/api/me');

// ✅ Correct: Sends httpOnly cookies
const response = await fetch('https://api.example.com/api/me', {
credentials: 'include'
});

// ✅ With Axios
import axios from 'axios';

const apiClient = axios.create({
baseURL: 'https://api.example.com',
withCredentials: true // Include cookies
});

CSRF Protection Strategies​

The SameSite cookie attribute prevents the browser from sending cookies on cross-site requests. This is the most effective CSRF defense:

// backend/routes/auth.js (Node.js/Express)
res.cookie('accessToken', token, {
httpOnly: true,
secure: true,
sameSite: 'Strict', // ← CSRF protection
maxAge: 15 * 60 * 1000
});

SameSite values:

  • Strict: Cookie is sent only for same-site requests. Prevents CSRF completely but breaks legitimate cross-site requests (e.g., clicking a link from Gmail to your app).
  • Lax: Cookie is sent for top-level navigation (clicking a link) and same-site requests, but not for cross-site form submissions. Best balance for most apps.
  • None: Cookie is sent for cross-site requests. Requires Secure: true. Use only when intentional (rare).
// Recommended settings
res.cookie('accessToken', token, {
httpOnly: true,
secure: true,
sameSite: 'Lax', // Or 'Strict' if your app does not use cross-site links
maxAge: 15 * 60 * 1000
});

Strategy 2: CSRF Tokens (For Forms)​

If you must support older browsers or need extra protection, use CSRF tokens. The server issues a unique, unpredictable token; the client includes it in POST/PUT/DELETE requests:

// backend: Issue CSRF token
app.get('/api/csrf-token', (req, res) => {
const csrfToken = crypto.randomBytes(32).toString('hex');

// Store in session or JWT (tied to user)
req.session.csrfToken = csrfToken;

res.json({ csrfToken });
});

// backend: Verify CSRF token on state-changing requests
app.post('/api/transfer', (req, res) => {
const csrfToken = req.headers['x-csrf-token'];

if (!csrfToken || csrfToken !== req.session.csrfToken) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}

// Process request
// ...
});

React client:

// React: Fetch CSRF token on app load
import { useEffect, useState } from 'react';

function useCSRFToken() {
const [csrfToken, setCSRFToken] = useState(null);

useEffect(() => {
fetch('/api/csrf-token', { credentials: 'include' })
.then(res => res.json())
.then(data => setCSRFToken(data.csrfToken));
}, []);

return csrfToken;
}

// Use in a form
function TransferForm() {
const csrfToken = useCSRFToken();

const handleSubmit = async (e) => {
e.preventDefault();

const response = await fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken // Include CSRF token in header
},
body: JSON.stringify({ amount: 100, to: 'alice' }),
credentials: 'include'
});

const data = await response.json();
// ...
};

return (
<form onSubmit={handleSubmit}>
<input type="number" placeholder="Amount" />
<button type="submit">Transfer</button>
</form>
);
}

Token-Specific Security Hardening​

1. Token Binding (Device Fingerprinting)​

Bind tokens to a device fingerprint to prevent token theft and reuse on different devices:

// backend: Include device fingerprint in token
function generateDeviceFingerprint(req) {
const userAgent = req.headers['user-agent'];
const acceptLanguage = req.headers['accept-language'];
const fingerprint = crypto
.createHash('sha256')
.update(userAgent + acceptLanguage)
.digest('hex');
return fingerprint;
}

app.post('/api/login', (req, res) => {
// ... validate credentials

const fingerprint = generateDeviceFingerprint(req);

const token = jwt.sign(
{
sub: user.id,
fingerprint: fingerprint
},
SECRET,
{ expiresIn: '15m' }
);

res.json({ token });
});

// Middleware: Verify fingerprint matches
function verifyTokenFingerprint(req, res, next) {
const token = req.cookies.accessToken;

if (!token) {
return res.status(401).json({ error: 'No token' });
}

try {
const decoded = jwt.verify(token, SECRET);
const currentFingerprint = generateDeviceFingerprint(req);

if (decoded.fingerprint !== currentFingerprint) {
return res.status(401).json({ error: 'Token does not match device' });
}

req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
}

2. Token Expiration and Rotation​

Keep tokens short-lived (5–15 minutes) and use refresh tokens for longer persistence:

// Good practice
const accessToken = jwt.sign(payload, SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign(payload, REFRESH_SECRET, { expiresIn: '7d' });

// Bad practice (no refresh token)
const token = jwt.sign(payload, SECRET, { expiresIn: '30d' });

3. Restrict Token Scopes​

If using OAuth, request only the scopes you need:

// ✅ Good: Minimal scopes
const scope = 'openid profile email';

// ❌ Bad: Excessive scopes
const scope = 'openid profile email offline_access user:admin repo delete';

4. Monitor and Audit​

Log authentication and token events:

// backend: Audit logging
function logAuthEvent(userId, eventType, metadata = {}) {
const event = {
userId,
eventType, // 'login', 'logout', 'token_refresh', 'failed_login', etc.
timestamp: new Date(),
ip: metadata.ip,
userAgent: metadata.userAgent,
...metadata
};

AuditLog.create(event);
}

// Usage
app.post('/api/login', async (req, res) => {
// ... validation

if (credentialsValid) {
logAuthEvent(user.id, 'login', { ip: req.ip, userAgent: req.get('user-agent') });
// ... issue token
} else {
logAuthEvent(null, 'failed_login', { email: req.body.email, ip: req.ip });
}
});

Complete Secure Backend Example​

// backend/server.js - Production-ready setup
const cors = require('cors');
const express = require('express');
const helmet = require('helmet');
const cookieParser = require('cookie-parser');

const app = express();

// Security headers
app.use(helmet());

// CORS for token-based auth
app.use(cors({
origin: 'https://app.example.com',
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 3600
}));

// Middleware
app.use(express.json());
app.use(cookieParser());

// Rate limiting (prevent brute-force login attacks)
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Max 5 login attempts
message: 'Too many login attempts; please try again later'
});

// Routes
app.post('/api/login', loginLimiter, async (req, res) => {
const { email, password } = req.body;

// Validate credentials
const user = await User.findByEmail(email);
if (!user || !await user.comparePassword(password)) {
logAuthEvent(null, 'failed_login', { email });
return res.status(401).json({ error: 'Invalid credentials' });
}

// Issue tokens
const accessToken = jwt.sign(
{ sub: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);

const refreshToken = jwt.sign(
{ sub: user.id },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);

// Set secure httpOnly cookie
res.cookie('accessToken', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'Lax', // CSRF protection
maxAge: 15 * 60 * 1000
});

res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'Lax',
maxAge: 7 * 24 * 60 * 60 * 1000
});

logAuthEvent(user.id, 'login');

return res.json({ user: { id: user.id, email: user.email } });
});

app.listen(3001);

Key Takeaways​

  • CORS and CSRF are separate threats: CORS allows/blocks cross-origin requests; CSRF prevents forged requests
  • Always set credentials: true in CORS config and credentials: 'include' in fetch/Axios for token-based auth
  • Use SameSite cookies (Strict or Lax) as the primary CSRF defense; it is more effective and requires no additional work
  • For extra protection, implement CSRF tokens in POST/PUT/DELETE requests; store on the server and verify on the client
  • Token binding (device fingerprinting) prevents stolen tokens from being used on different devices
  • Keep access tokens short-lived (5–15 minutes) and use refresh tokens for longer sessions
  • Request only the scopes you need; excessive scopes increase risk if tokens are compromised
  • Monitor and audit authentication events (login, logout, failed attempts) for suspicious activity
  • Use rate limiting on login endpoints to prevent brute-force attacks
  • Test CORS configuration with external tools (curl from another domain, browser DevTools Network tab)

Frequently Asked Questions​

Can I use Access-Control-Allow-Origin: * with credentials?​

No. Browsers block this combination as unsafe. If you set credentials: true, you must specify an exact origin (e.g., https://app.example.com), not a wildcard.

Is SameSite enough to prevent all CSRF attacks?​

For modern browsers (Chrome, Firefox, Safari, Edge from 2020+), yes. However, older browsers do not support SameSite. If you must support Internet Explorer 11, also implement CSRF tokens.

What if my frontend and backend are on the same domain?​

CORS is not triggered (same-origin). You still need CSRF protection, especially for forms that use cookies (though tokens in headers are safer). Use SameSite cookies and CSRF tokens as a defense-in-depth strategy.

Yes, this is often called "token in header" pattern. Tokens in headers are not sent on cross-site requests (because the header must be explicitly added by JavaScript), so CSRF is less of a concern. However, tokens must be stored somewhere (localStorage is vulnerable to XSS). httpOnly cookies are more secure.

What if I need to support sub-domain cross-origin requests?​

Set CORS origin to the parent domain: https://*.example.com (if supported by your CORS library), or list each subdomain explicitly. Be careful: if you own example.com, any user with a subdomain (like attacker.example.com) can access your API if CORS is too permissive.

Further Reading​