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