HTTP-only Cookie Authentication
Authentication using HTTP-only Cookies is an implementation method of session-based authentication that places particular emphasis on security.
What is an HTTP-only Cookie
Cookies with the HTTP-only attribute cannot be accessed from JavaScript, making them highly resistant to XSS attacks.
Difference from Normal Cookies
// Normal Cookie (Accessible from JavaScript)
document.cookie = "token=abc123; path=/";
console.log(document.cookie); // "token=abc123" can be retrieved
// HTTP-only Cookie (Not accessible from JavaScript)
// Can only be set on the server side
// Cannot be retrieved with document.cookie
Cookie Attributes
| Attribute | Description | Recommended Value |
|---|---|---|
| HttpOnly | Prevents access from JavaScript | true |
| Secure | Sent only over HTTPS connections | true |
| SameSite | Prevents CSRF attacks | Strict or Lax |
| Path | Path where the Cookie is sent | / |
| Max-Age / Expires | Cookie expiration | Set according to session |
| Domain | Domain where the Cookie is valid | Not set or own domain |
Authentication Flow
Basic Flow
Set-Cookie Header Example
HTTP/1.1 200 OK
Set-Cookie: sessionId=abc123def456; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600
Content-Type: application/json
Implementation Examples
Server-Side Implementation
Node.js + Express + express-session
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const bcrypt = require('bcrypt');
const app = express();
// Redis Client Configuration
const redisClient = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379'
});
redisClient.connect();
// Session Configuration
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // Not accessible from JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 1000 * 60 * 60 * 24, // 24 hours
},
}));
app.use(express.json());
// Login Endpoint
app.post('/auth/login', async (req, res) => {
const { username, password } = req.body;
try {
// User verification (actually retrieve from database)
const user = await getUserByUsername(username);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Password verification
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Save user info in session
req.session.userId = user.id;
req.session.username = user.username;
res.json({
success: true,
user: {
id: user.id,
username: user.username,
email: user.email,
},
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Logout Endpoint
app.post('/auth/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Failed to logout' });
}
res.clearCookie('connect.sid'); // Delete session cookie
res.json({ success: true });
});
});
// Authentication Middleware
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
next();
}
// Protected Endpoint
app.get('/api/users/me', requireAuth, (req, res) => {
res.json({
id: req.session.userId,
username: req.session.username,
});
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
ASP.NET Core
using Microsoft.AspNetCore.Authentication.Cookies;
var builder = WebApplication.CreateBuilder(args);
// Cookie Authentication Configuration
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;
options.ExpireTimeSpan = TimeSpan.FromHours(24);
options.SlidingExpiration = true;
options.LoginPath = "/auth/login";
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// Login Endpoint
app.MapPost("/auth/login", async (HttpContext context, LoginRequest request) =>
{
// User verification (implementation omitted)
var user = await AuthenticateUser(request.Username, request.Password);
if (user == null)
{
return Results.Unauthorized();
}
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.Email, user.Email),
};
var claimsIdentity = new ClaimsIdentity(claims,
CookieAuthenticationDefaults.AuthenticationScheme);
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity));
return Results.Ok(new { success = true, user = new { user.Id, user.Username } });
});
// Logout Endpoint
app.MapPost("/auth/logout", async (HttpContext context) =>
{
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.Ok(new { success = true });
});
// Protected Endpoint
app.MapGet("/api/users/me", (ClaimsPrincipal user) =>
{
return Results.Ok(new
{
id = user.FindFirst(ClaimTypes.NameIdentifier)?.Value,
username = user.FindFirst(ClaimTypes.Name)?.Value,
});
})
.RequireAuthorization();
app.Run();
record LoginRequest(string Username, string Password);
Client-Side Implementation
JavaScript (Fetch API)
// Login
async function login(username, password) {
const response = await fetch('https://api.example.com/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // Mandatory to send/receive Cookies
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
throw new Error('Login failed');
}
return await response.json();
}
// Call API requiring authentication
async function fetchUserProfile() {
const response = await fetch('https://api.example.com/api/users/me', {
method: 'GET',
credentials: 'include', // Automatically send Cookies
});
if (!response.ok) {
if (response.status === 401) {
// Authentication error: Redirect to login screen
window.location.href = '/login';
return;
}
throw new Error('Failed to fetch profile');
}
return await response.json();
}
// Logout
async function logout() {
const response = await fetch('https://api.example.com/auth/logout', {
method: 'POST',
credentials: 'include',
});
if (response.ok) {
window.location.href = '/login';
}
}
Example Implementation in React
import { createContext, useContext, useState, useEffect } from 'react';
interface User {
id: string;
username: string;
email: string;
}
interface AuthContextType {
user: User | null;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Check session on initialization
useEffect(() => {
checkSession();
}, []);
async function checkSession() {
try {
const response = await fetch('/api/users/me', {
credentials: 'include',
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
}
} catch (error) {
console.error('Session check failed:', error);
} finally {
setIsLoading(false);
}
}
async function login(username: string, password: string) {
const response = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
throw new Error('Login failed');
}
const data = await response.json();
setUser(data.user);
}
async function logout() {
await fetch('/auth/logout', {
method: 'POST',
credentials: 'include',
});
setUser(null);
}
return (
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
Security Considerations
CSRF (Cross-Site Request Forgery) Protection
HTTP-only Cookies are vulnerable to CSRF attacks. The following measures are required:
1. Use SameSite Attribute
cookie: {
sameSite: 'strict', // or 'lax'
// ...
}
- Strict: Cookie is sent only for requests from the same site.
- Lax: Sent for safe methods like GET.
- None: Sent for all requests (Secure attribute required).
2. Use CSRF Tokens
const csurf = require('csurf');
// CSRF protection middleware
const csrfProtection = csurf({ cookie: false }); // Use session store
app.get('/api/csrf-token', csrfProtection, (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
app.post('/api/important-action', csrfProtection, (req, res) => {
// CSRF token is verified
res.json({ success: true });
});
Client side:
// Get CSRF token
const csrfResponse = await fetch('/api/csrf-token', {
credentials: 'include',
});
const { csrfToken } = await csrfResponse.json();
// Send token with request
await fetch('/api/important-action', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
credentials: 'include',
body: JSON.stringify({ /* data */ }),
});
CORS Configuration
If the client and server are on different domains, appropriate CORS configuration is required.
const cors = require('cors');
app.use(cors({
origin: 'https://yourapp.com', // Allow only specific origin
credentials: true, // Allow sending/receiving Cookies
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'X-CSRF-Token'],
}));
Session Fixation Attack Protection
Regenerate the session ID upon successful login.
app.post('/auth/login', async (req, res) => {
const user = await authenticateUser(req.body.username, req.body.password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Regenerate session ID (prevent session fixation attacks)
req.session.regenerate((err) => {
if (err) {
return res.status(500).json({ error: 'Session error' });
}
req.session.userId = user.id;
res.json({ success: true, user });
});
});
Choosing a Session Store
In production environments, use a persistent store instead of memory-based.
- Redis: Fast and recommended
- MongoDB: Document-oriented
- PostgreSQL: Relational DB
- Memcached: Distributed cache
// Example using Redis
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const redisClient = createClient({
url: process.env.REDIS_URL,
password: process.env.REDIS_PASSWORD,
});
app.use(session({
store: new RedisStore({
client: redisClient,
prefix: 'sess:',
ttl: 86400, // 24 hours
}),
// ...
}));
Comparison with Bearer Authentication
| Item | HTTP-only Cookie | Bearer Token |
|---|---|---|
| XSS Resistance | High (JavaScript disabled) | Low (when using LocalStorage) |
| CSRF Resistance | Low (measures required) | High |
| Mobile Support | Somewhat difficult | Easy |
| Statelessness | Low (session management required) | High |
| CORS Support | Somewhat complex | Simple |
| Microservices | Somewhat unsuitable | Suitable |
| Implementation Complexity | Medium | Medium |
Usage Scenarios
When HTTP-only Cookie is Suitable
- Traditional Web applications (Server-Side Rendering)
- Applications within the same domain
- When prioritizing XSS protection
- When session management is required
When Bearer Token is Suitable
- SPA (Single Page Application)
- Mobile apps
- Microservice architecture
- API-first design
- When cross-domain authentication is required
Hybrid Approach
It is also possible to combine the advantages of both:
// Save session ID in HTTP-only Cookie
// Manage access token in memory
app.post('/auth/login', async (req, res) => {
const user = await authenticateUser(req.body);
// Create session (HTTP-only Cookie)
req.session.userId = user.id;
// Issue short-lived access token
const accessToken = generateAccessToken(user.id);
res.json({
accessToken, // Manage in memory
expiresIn: 900, // 15 minutes
});
});
Summary
- HTTP-only Cookie is an authentication method resistant to XSS attacks.
- CSRF protection (SameSite, CSRF token) is mandatory.
- Server-side implementation is required for session management.
- Enable client-side Cookie transmission/reception with
credentials: 'include'. credentials: trueis required when configuring CORS.- Use a persistent session store like Redis in production environments.