Origin / Referer Check
The Origin header tells the server which origin initiated the request — https://app.example.com, etc. Browsers send it on cross-origin POSTs. Server-side allowlist check + SameSite cookies kill most CSRF.
Origin vs Referer, allowlist, edge cases
EXAMPLE
# 1) The headers
# Origin : scheme + host + port (no path). Sent on cross-origin requests AND POSTs.
# Referer : full URL (scheme + host + path + query). May be omitted for privacy.
# When does the browser send Origin?
# - All cross-origin requests (CORS)
# - POST / PUT / DELETE / PATCH (including same-origin)
# - WebSocket / Server-Sent Events / fetch with credentials
# - NOT sent on same-origin GET requests in some configs
# When does the browser send Referer?
# - Most requests, but can be omitted via:
# <meta name="referrer" content="no-referrer">
# Referrer-Policy: no-referrer
# rel="noreferrer" on links
# - Privacy modes (incognito + strict tracking protection) may strip
# 2) Server-side origin check (Express)
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
]);
app.use((req, res, next) => {
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
const origin = req.headers.origin;
const referer = req.headers.referer;
// Origin is more reliable than Referer when present
if (origin) {
if (!ALLOWED_ORIGINS.has(origin)) {
return res.status(403).json({ error: 'invalid origin' });
}
} else if (referer) {
// Fall back to Referer
try {
const refOrigin = new URL(referer).origin;
if (!ALLOWED_ORIGINS.has(refOrigin)) {
return res.status(403).json({ error: 'invalid referer' });
}
} catch {
return res.status(403).json({ error: 'invalid referer' });
}
} else {
// No Origin or Referer — possibly stripped by privacy mode
// Decision: reject (strict) or pass to a downstream check like CSRF token (lax)
return res.status(403).json({ error: 'missing origin and referer' });
}
}
next();
});
# 3) Per-route origin check
function requireOrigin(allowed) {
return (req, res, next) => {
if (!allowed.has(req.headers.origin)) {
return res.status(403).end();
}
next();
};
}
app.post('/api/transfer',
requireOrigin(new Set(['https://app.example.com'])),
requireAuth,
transfer,
);
# 4) Wildcard / pattern allowlist
// Be careful with patterns — don't allow open subdomains
function isAllowedOrigin(origin) {
if (!origin) return false;
try {
const u = new URL(origin);
// Match specific origins
if (ALLOWED_ORIGINS.has(origin)) return true;
// Match preview deploys: https://pr-123.preview.example.com
if (u.hostname.endsWith('.preview.example.com')) return true;
return false;
} catch { return false; }
}
# 5) Cross-origin POST behaviour
# Browser sends Origin on cross-origin POST automatically.
# Attacker page at evil.com submits a form to https://example.com/transfer.
# Browser sends: Origin: https://evil.com
# Server check rejects → CSRF attempt blocked.
# 6) Combine with SameSite cookies (the strongest layer)
res.cookie('sid', sid, {
sameSite: 'lax', // browser blocks cross-site cookie on POST/PUT/DELETE
secure: true,
httpOnly: true,
});
# SameSite=Lax already stops 99% of CSRF without any server check.
# Origin check is BELT + BRACES.
# 7) Edge cases
# a) Same-origin POST — Origin is set to the same origin
Origin: https://app.example.com
# Allow if it matches ALLOWED_ORIGINS.
# b) Same-origin GET — Origin may or may not be set
# Don't enforce on GET (idempotent anyway).
# c) Stripped Origin by browser extension / privacy tool
# Server can fall back to Referer, or require CSRF token, or accept the risk
# (very small fraction of users).
# d) Tor browser sets Origin: null
# Decision: handle 'null' explicitly (reject for security, or allow + require CSRF token).
if (origin === 'null') {
return res.status(403).end();
}
# e) Redirect chain — Origin doesn't change on cross-origin POST after redirect
# Server still sees the original origin.
# 8) CORS vs Origin check
# CORS : tells the BROWSER what cross-origin requests to permit
# Origin check : tells the SERVER which origins to trust
# Both are needed — they protect different sides.
# 9) Common bugs
# ❌ Trusting only Referer (can be stripped by user / privacy)
# ❌ Allowing any subdomain (subdomain takeover → CSRF via Origin)
# ❌ Comparing as substring (origin.includes('example.com')) → 'evilexample.com' bypass
# ❌ Lowercasing origin and comparing — origins are case-sensitive
# ❌ Trusting Origin without checking method (GET is exempt; that's fine)
# ❌ Setting Access-Control-Allow-Origin: * with credentials — invalid spec; browsers reject
# 10) Always-do checklist
# ✅ SameSite=Lax (or Strict) on the session cookie
# ✅ Origin check on mutating requests with explicit allowlist
# ✅ Reject 'null' origin or require CSRF token
# ✅ Strict CORS (origin: specific list, not '*')
# ✅ HTTPS-only — Origin over HTTP is unreliable in MITM scenarios
# 11) Per-framework patterns
# Express — middleware shown above
# Django
# CSRF_TRUSTED_ORIGINS = ['https://app.example.com']
# CsrfViewMiddleware also validates Origin / Referer on POST
# Rails (5+)
# Configured via protect_from_forgery; verifies authenticity token AND Origin matches
# Laravel
# VerifyCsrfToken middleware checks token; sanctum config has stateful_domains for SPA scope
# Spring Security
# csrf-token mechanism + SameSite enforcement; can configure custom origin/referer checks
# 12) Production patterns
# Defence-in-depth stack for mutating endpoints:
# 1. SameSite=Lax session cookie (browser-side)
# 2. Strict CORS (browser-side)
# 3. Origin header validation (allowlist) (server-side)
# 4. CSRF token (synchronizer or double-submit)
# 5. Re-auth for sensitive actions (password / MFA)
# 6. Audit log of mutations
# Any ONE of layers 1-4 stops a textbook CSRF. Stack them; assume any layer can fail.
# 13) Anti-patterns
# ❌ Only checking Origin OR only checking Referer OR only using CSRF tokens
# ❌ Trusting the Origin equals 'null' silently
# ❌ Allowing wildcard subdomains without monitoring takeover risk
# ❌ Ignoring Origin on the assumption SameSite is enough — older browsers don't enforce
# ❌ Mixing 'CORS as security' with 'CORS as a flexibility tool'
# 14) Testing
# curl -X POST https://api.example.com/api/transfer \
# -H 'Origin: https://evil.com' \
# -H 'Cookie: sid=abc...' \
# -d 'amount=1000&to=victim'
# Expected: 403 Forbidden
# curl -X POST https://api.example.com/api/transfer \
# -H 'Origin: https://app.example.com' \
# -H 'Cookie: sid=abc...' \
# -d 'amount=1000&to=victim'
# Expected: 200 (or 401 if cookie isn't valid)
# 15) Migration story
# An app without origin checks:
# 1. Add `SameSite=Lax` first (covers most cases) — no breaking changes for same-site users
# 2. Audit cross-origin needs (legitimate embeds, OAuth callbacks)
# 3. Add Origin allowlist on mutating endpoints
# 4. Reject null / missing Origin (start with logging; switch to reject after monitoring)
# 5. Add CSRF token for SameSite=None scenarios
Why it matters
Origin check + SameSite=Lax + strict CORS is the modern CSRF triple-stack. Each blocks a different attack vector; together they make CSRF practically impossible without an additional XSS or subdomain takeover.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// On state-changing endpoints, verify Origin (preferred) or Referer. const allowed = ['https://app.example.com']; if (!allowed.includes(req.headers.origin)) return res.status(403).end();Try it Yourself »
Exercise
Header used to verify the calling origin.
if (req.headers.
!== expected) return res.status(403).end();
Six letters.
Discussion
Loading…