Anomaly Monitoring
CSRF defences (SameSite cookies, double-submit tokens, CORS, custom headers) only protect you if you can DETECT when they fail. Wiring metrics, alerts, and dashboards around CSRF-relevant signals turns a quiet attack into a 5-minute response time.
Metrics, logs, alerts, anomaly detection
EXAMPLE
// 1) Instrument the CSRF middleware itself
import express from 'express';
import crypto from 'node:crypto';
import { metrics } from './telemetry.js'; // Prometheus / OpenTelemetry / DataDog
const csrfFailures = metrics.counter({
name: 'csrf_failures_total',
help: 'CSRF check failures',
labelNames: ['route', 'reason', 'environment'],
});
function requireCsrf(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const header = req.get('X-CSRF-Token');
const cookie = req.cookies['csrf-token'];
let reason;
if (!cookie) reason = 'missing-cookie';
else if (!header) reason = 'missing-header';
else if (header.length !== cookie.length || !crypto.timingSafeEqual(Buffer.from(header), Buffer.from(cookie))) reason = 'mismatch';
if (reason) {
csrfFailures.inc({ route: req.path, reason, environment: process.env.NODE_ENV });
req.log.warn({ reason, path: req.path, ip: req.ip, ua: req.get('user-agent'), origin: req.get('origin') },
'csrf failure');
return res.status(403).json({ error: 'CSRF check failed' });
}
next();
}
// 2) Tag every CSRF failure with structured context
// MUST include: route, reason, IP, user agent, origin, referrer, authenticated user id if any
// MUST NOT include: the actual token (would leak it to logs)
// 3) Log + metric for OPTIONS / CORS rejections
const corsRejections = metrics.counter({
name: 'cors_rejections_total',
help: 'CORS allowlist rejections',
labelNames: ['origin', 'route'],
});
const ALLOWED = new Set(['https://app.example.com']);
app.use((req, res, next) => {
const origin = req.get('origin');
if (origin && !ALLOWED.has(origin)) {
corsRejections.inc({ origin, route: req.path });
req.log.warn({ origin, path: req.path }, 'cors rejection');
// Don't set Allow-Origin; browser blocks
}
next();
});
// 4) Counter — Origin / Referer presence
// Most browsers send Origin on cross-origin POST. Missing Origin on a state-changing POST
// is suspicious (or a bot / old client).
const missingOrigin = metrics.counter({ name: 'state_change_missing_origin_total', help: 'POST/PUT/etc without Origin', labelNames: ['route'] });
app.use((req, res, next) => {
if (['POST','PUT','PATCH','DELETE'].includes(req.method) && !req.get('origin')) {
missingOrigin.inc({ route: req.path });
req.log.info({ path: req.path }, 'state change without origin');
}
next();
});
// 5) Dashboards — what to visualise
// • csrf_failures_total by route, reason — heatmap; small steady noise + spikes are flag-worthy
// • cors_rejections_total by origin — find new attacker domains
// • state_change_missing_origin_total by route — track over time
// • ratio: csrf_failures / total_state_changing_requests — % of requests blocked by CSRF
// • TTR — time between first failure spike and acknowledgement
// 6) Alerting — Prometheus example
// alerts.yml
groups:
- name: csrf
rules:
- alert: CSRFFailureSpike
expr: |
sum(rate(csrf_failures_total[5m])) by (route)
> 5 * sum(rate(csrf_failures_total[1h] offset 1h)) by (route)
for: 5m
labels: { severity: page }
annotations:
summary: 'CSRF failure rate 5x normal on {{ $labels.route }}'
runbook: 'https://runbooks.example.com/csrf-spike'
- alert: CSRFNewOriginRejected
expr: |
count(rate(cors_rejections_total[30m])) > 0
unless count(rate(cors_rejections_total[7d] offset 7d)) > 0
labels: { severity: ticket }
annotations:
summary: 'New CORS origin being rejected (potential attempt or third-party integration)'
// 7) WAF / API gateway integration
// • Cloudflare, AWS WAF — block known-bad origins automatically
// • Rate-limit by IP + endpoint for repeated CSRF failures
// • Trigger CAPTCHA on suspected attack origin
// • Log WAF actions to the same SIEM as app logs for correlation
// 8) Detect token misuse patterns
// • Same CSRF token submitted from many different IPs → potential cookie theft
// • Token used after session was revoked → check session invalidation
// • Token submitted by an unauthenticated request → frontend bug or scraper
const tokenSeen = new Map(); // token → Set<ip>, with TTL eviction
function recordTokenUsage(token, ip) {
const set = tokenSeen.get(token) ?? new Set();
set.add(ip);
tokenSeen.set(token, set);
if (set.size > 5) {
log.warn({ token: token.slice(0, 8) + '…', size: set.size }, 'csrf token used from many IPs');
}
}
// 9) End-to-end SLO
// Define: '99.9% of state-changing requests succeed CSRF check'
// If you drop below, page on-call. Below 99% — major incident.
// 10) Periodic synthetic checks
// Cron-driven: every 5 minutes, hit a known state-changing endpoint as a logged-in test user
// • without the CSRF token → must receive 403
// • with the CSRF token → must receive 2xx
// Alert if either expectation fails.
// 11) Tabletop drills
// Once a quarter, simulate:
// • CSRF token issuance breaks in production (cookie not set)
// • Hostile origin starts probing /login
// • Token spec changes (rotation)
// Run through the runbook. Time-to-detect, time-to-acknowledge, time-to-resolve.
// 12) PII + log hygiene
// • NEVER log the actual CSRF token value — that's a session-equivalent secret
// • Log a token PREFIX or hash for correlation only
// • Strip Cookie + Authorization headers from incoming request logs
// • Anonymise IPs as required by GDPR / your privacy policy
// 13) Integrate with SIEM (Splunk / DataDog / Sentinel)
// • Forward csrf-failure logs as a SIEM event class
// • Correlate with auth-failure + IDOR-attempt events for a single attacker view
// • Set retention to meet compliance (90+ days typical)
// 14) Mobile + SDK monitoring
// • Native apps that hit your API still need CSRF if they use cookie auth (via WebView)
// • For pure bearer-token APIs, CSRF doesn't apply — but track per-token usage anomalies
// • SDK telemetry — count token failures per SDK version + OS
// 15) Common bugs / oversights
// • CSRF failures land in app logs but no metric → invisible to ops
// • Alert configured but routing to a silent Slack channel → noise
// • Aggressive blocking on token mismatch + bad UX (no retry) → users perceive bugs as outages
// • Logging the token value → secret in logs
// • Distinguish 'token expired' from 'token missing' from 'token mismatch' — only mismatch is a likely attack
// • Excluding GETs from logging — GETs shouldn't change state but may carry tokens used by JS; minimal logging is fine
// • Failing to alert on a quiet route — a sudden CSRF failure on an endpoint nobody hits is the LOUDEST signal
// • No correlation across services — attacker probes app A, then app B; cross-service dashboards catch it
Why it matters
Every CSRF defence needs a counter, a structured log, and an alert — otherwise a quiet attack goes unseen. Track failures by route and reason, dashboard them, alert on baseline spikes, and run periodic synthetic checks so you catch broken token issuance before users do. Never log the token value itself.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// Alert on: spike in 403 csrf rejections, cross-origin POST attempts, // distribution of Referer values, sudden burst of new accounts.Try it Yourself »
Discussion
Loading…