Skip to main content

HTTP-only Cookie Authentication

Authentication using HTTP-only Cookies is an implementation method of session-based authentication that places particular emphasis on security.

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
AttributeDescriptionRecommended Value
HttpOnlyPrevents access from JavaScripttrue
SecureSent only over HTTPS connectionstrue
SameSitePrevents CSRF attacksStrict or Lax
PathPath where the Cookie is sent/
Max-Age / ExpiresCookie expirationSet according to session
DomainDomain where the Cookie is validNot set or own domain

Authentication Flow

Basic Flow

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

ItemHTTP-only CookieBearer Token
XSS ResistanceHigh (JavaScript disabled)Low (when using LocalStorage)
CSRF ResistanceLow (measures required)High
Mobile SupportSomewhat difficultEasy
StatelessnessLow (session management required)High
CORS SupportSomewhat complexSimple
MicroservicesSomewhat unsuitableSuitable
Implementation ComplexityMediumMedium

Usage Scenarios

  • 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: true is required when configuring CORS.
  • Use a persistent session store like Redis in production environments.

References