Skip to main content

httpOnly Cookies: Secure Token Storage in React Apps

An httpOnly cookie is a browser cookie that the server sets with the HttpOnly flag, making it inaccessible to JavaScript (including XSS attacks). The browser automatically includes httpOnly cookies in HTTP requests to the matching domain, and your React app communicates with the backend transparently. This article covers the three critical cookie flags (HttpOnly, Secure, SameSite), how to set them from your backend, and how to configure React's fetch API to send cookies.

HttpOnly Flag: Block JavaScript Access

When a cookie is set with HttpOnly: true, the browser stores it but JavaScript cannot read, write, or delete it. An XSS attack cannot steal it because document.cookie and the fetch API cannot access it. The browser automatically includes it in requests to the matching domain.

Secure Flag: HTTPS-Only Transmission

The Secure flag tells the browser to send the cookie only over HTTPS. If you try to set a Secure cookie over HTTP, the browser rejects it. This prevents man-in-the-middle (MITM) attacks where an attacker intercepts HTTP traffic and steals the cookie.

SameSite Attribute: CSRF Prevention

The SameSite attribute prevents Cross-Site Request Forgery (CSRF) attacks. It tells the browser whether to send the cookie when the request originates from a different site. Values are:

  • Strict: Cookie is sent only for same-site requests. Safest, but can break cross-site integrations.
  • Lax: Cookie is sent for top-level navigation (clicking a link) and same-site requests, but not for cross-site form submissions. Default in modern browsers; a good balance.
  • None: Cookie is sent for cross-site requests. Requires Secure: true. Use only when intentional (e.g., cross-origin embedded widgets).

Setting httpOnly Cookies from the Backend

Node.js / Express Example

const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();

const SECRET = process.env.JWT_SECRET;
const REFRESH_SECRET = process.env.REFRESH_SECRET;

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

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

// Create access token (short-lived)
const accessToken = jwt.sign(
{ sub: user.id, email: user.email, type: 'access' },
SECRET,
{ expiresIn: '15m' }
);

// Create refresh token (long-lived)
const refreshToken = jwt.sign(
{ sub: user.id, type: 'refresh' },
REFRESH_SECRET,
{ expiresIn: '7d' }
);

// Set httpOnly cookie with access token
res.cookie('accessToken', accessToken, {
httpOnly: true, // JavaScript cannot access
secure: true, // HTTPS only in production
sameSite: 'Strict', // CSRF protection
maxAge: 15 * 60 * 1000, // 15 minutes in milliseconds
path: '/', // Available on all paths
domain: 'api.example.com' // Restrict to specific domain (optional)
});

// Set refresh token cookie (longer lived, can be in httpOnly or separate)
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/refresh' // Restrict to refresh endpoint
});

// Return user info (not the token; it is in the cookie)
res.json({ user: { id: user.id, email: user.email } });
});

Python / Flask Example

from flask import Flask, request, jsonify, make_response
import jwt
from datetime import datetime, timedelta

app = Flask(__name__)
SECRET = os.getenv('JWT_SECRET')

@app.route('/api/login', methods=['POST'])
def login():
data = request.json
email = data.get('email')
password = data.get('password')

# Validate email/password (pseudocode)
user = User.query.filter_by(email=email).first()
if not user or not user.check_password(password):
return jsonify({'error': 'Invalid credentials'}), 401

# Create token
access_token = jwt.encode(
{
'sub': user.id,
'email': user.email,
'exp': datetime.utcnow() + timedelta(minutes=15),
'iat': datetime.utcnow()
},
SECRET,
algorithm='HS256'
)

# Create response and set httpOnly cookie
response = make_response(jsonify({'user': {'id': user.id, 'email': user.email}}))
response.set_cookie(
'accessToken',
access_token,
httponly=True, # Python uses lowercase
secure=True,
samesite='Strict',
max_age=15 * 60, # 15 minutes
path='/'
)

return response, 200

Configuring React to Send Cookies with Requests

By default, the fetch API does not send cookies to the backend. You must explicitly set credentials: 'include' to send httpOnly cookies (and any other cookies).

Fetch with Credentials

// ✅ Correct: Include cookies in fetch request
async function loginUser(email, password) {
const response = await fetch('https://api.example.com/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password }),
credentials: 'include' // ← CRITICAL: Send cookies
});

if (!response.ok) {
throw new Error('Login failed');
}

return response.json(); // { user: { id, email } }
// The httpOnly cookie is automatically handled by the browser
}

Axios Configuration

If you are using Axios, set withCredentials: true:

import axios from 'axios';

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

async function loginUser(email, password) {
const response = await apiClient.post('/api/login', { email, password });
return response.data; // { user: { id, email } }
}

When cookies are involved, CORS (Cross-Origin Resource Sharing) requires additional configuration:

Backend CORS Configuration

// Node.js/Express with CORS
const cors = require('cors');

app.use(cors({
origin: 'https://app.example.com', // Exact frontend domain
credentials: true, // Allow cookies in CORS requests
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));

Important: Frontend and Backend Domains

  • If frontend is https://app.example.com and backend is https://api.example.com (different subdomains), CORS is required.
  • The domain attribute in the cookie can be set to .example.com (with a dot) to share the cookie across subdomains.
  • If frontend and backend are on the same domain (e.g., both https://example.com), CORS is not needed, but the path attribute still applies.

Complete React Login Component with httpOnly Cookies

import React, { useState } from 'react';

function SecureLoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');

const handleLogin = async (e) => {
e.preventDefault();
setLoading(true);
setError('');

try {
// ✅ Fetch with credentials to send/receive cookies
const response = await fetch('https://api.example.com/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password }),
credentials: 'include' // Include httpOnly cookies
});

if (!response.ok) {
const data = await response.json();
setError(data.error || 'Login failed');
return;
}

const data = await response.json();
console.log('Logged in as:', data.user.email);

// ✅ No manual token handling; cookie is managed by the browser
// Redirect to dashboard
window.location.href = '/dashboard';

} catch (err) {
setError('Network error: ' + err.message);
} finally {
setLoading(false);
}
};

return (
<form onSubmit={handleLogin}>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="Email"
disabled={loading}
/>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Password"
disabled={loading}
/>
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Log In'}
</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
</form>
);
}

export default SecureLoginForm;

Verifying Cookies in the Browser

You can inspect cookies in the browser DevTools:

  1. Open DevTools (F12)
  2. Go to Application > Cookies > Select your domain
  3. Verify HttpOnly is checked, Secure is checked (in production), and SameSite is set

Note: The DevTools show HttpOnly as a flag, but you cannot read the value (which is the point).

Key Takeaways

  • httpOnly cookies block JavaScript from reading tokens, making them immune to XSS
  • Always set Secure (HTTPS-only) and SameSite (Strict or Lax) flags
  • Set credentials: 'include' in React fetch requests to send cookies
  • The browser automatically includes httpOnly cookies; React does not store or manage tokens
  • Backend must set CORS credentials: true and specify the frontend origin to allow cookie transmission
  • Test cookie behavior in both production and development; cookie domains and paths differ between localhost and production domains
  • Use short-lived access tokens (5–15 minutes) with httpOnly cookies; pair with refresh tokens for extended sessions

Frequently Asked Questions

Why do I need to set credentials: 'include' in fetch?

By default, fetch does not send cookies to protect against CSRF. You must explicitly enable it. If you forget, the browser will not send the httpOnly cookie, and the backend will see you as unauthenticated.

No. An httpOnly cookie is read-only from the browser's perspective. You cannot access its value with JavaScript, but the browser automatically includes it in requests. You can only delete it from JavaScript if it is not httpOnly; the server must explicitly unset it.

What happens if I set Secure: false in production?

The browser will reject the cookie over HTTPS. In development (localhost), you may set secure: false to test over HTTP, but production must always use secure: true over HTTPS.

Does httpOnly protect against all attacks?

httpOnly prevents XSS token theft and MITM attacks if paired with HTTPS. However, it does not protect against server-side breaches (if your backend server is compromised) or physical device theft. Defense in depth requires multiple layers: CSP, HTTPS, short token lifetimes, refresh-token rotation, and monitoring.

Only if the cookie is not httpOnly. If it is httpOnly, you cannot read it; the browser manages it invisibly. You can check if the user is logged in by making a request (e.g., GET /api/me) and seeing if it succeeds.

Further Reading