Token Validation Flaws
Token not checked, checked only when present, empty accepted, not tied to the session, reused across users, or predictable.
The token is usually there. The check is what fails.
Most applications you test will have a CSRF token in the form. That is not the question. The question is what the server does with it, and the answer is frequently "less than you would think".
These flaws are worth testing in order, because they take about a minute each and the first hit ends the exercise. Each one is a single modified request in your proxy — no PoC needed until you have found the gap.
The most common finding by a wide margin is the token is validated but not bound to the session: the server checks that the token is well-formed, or that it exists in a table, but never that it belongs to this user. An attacker registers an account, collects their own valid token, and puts it in the victim's request. It validates.
The test sequence
The big one: not bound to the session
# Tokens are stored in a global table with no owner.
VALID_TOKENS = set()
def issue_token():
token = secrets.token_urlsafe(32)
VALID_TOKENS.add(token)
return token
def change_email(request):
submitted = request.POST.get('csrf_token', '')
# The token is unpredictable, high-entropy, and correctly compared.
# It is still useless: it proves only that SOMEBODY was issued this
# token, not that THIS user was.
#
# The attacker registers an account, loads any form, copies their own
# token, and embeds it in the PoC aimed at the victim. It validates.
if submitted not in VALID_TOKENS:
return HttpResponseForbidden()
request.user.email = request.POST['email']
request.user.save()Conditional validation
// Four spellings of the same bug. All of them mean "delete the parameter
// and the check does not run".
// 1. The truthiness guard. The single most common CSRF bug in the wild.
if (req.body.csrf_token && req.body.csrf_token !== session.csrfToken) {
return res.status(403).send('CSRF')
}
// 2. The optional-chaining variant. Same behaviour, looks more modern.
if (req.body?.csrf_token !== undefined && req.body.csrf_token !== session.csrfToken) {
return res.status(403).send('CSRF')
}
// 3. Header-only validation. Delete the header and nothing is checked --
// and a form cannot set headers anyway, so this only ever protected
// the XHR path.
const header = req.get('X-CSRF-Token')
if (header && header !== session.csrfToken) {
return res.status(403).send('CSRF')
}
// 4. Empty-string equivalence. If the session has no token yet, expected is
// undefined; String(undefined) === String(req.body.csrf_token) when the
// parameter is also absent, and loose comparison lets it through.
if (req.body.csrf_token != session.csrfToken) {
return res.status(403).send('CSRF')
}
// The fix in every case: default-deny. Read into a variable with a default of
// '', require the expected value to be non-empty, and compare in constant
// time. Never make the check conditional on the attacker-supplied value
// existing.Predictable tokens
Less common than it used to be, but still found — especially in internal and legacy applications. Signs to look for, and all of them are visible just by collecting a handful of tokens:
- The token equals the session ID, or a prefix or hash of it. If you can see the session cookie, you have the token — and any XSS or log leak gives both at once.
- It is a hash of something guessable: username, user ID, email, or a timestamp. Try
md5(username),sha1(userid),md5(email)against the observed value. - It is a counter or contains one. Collect ten tokens across ten requests and diff them; sequential structure is obvious.
- It encodes a timestamp. Base64-decode it and look. A token that is
base64(userid:timestamp)is forgeable given the user id. - It never changes across accounts. Register two accounts and compare — if they share a token, it is a constant, not a token.
- It is short. Under 16 hex characters is worth a brute-force estimate, especially if the endpoint is not rate-limited.
Base64-decode every token you see as a matter of routine. A surprising number decode to something structured and readable.
Reporting these well
A token flaw makes the finding much stronger than a plain missing-token report, because it demonstrates that the control exists and does not work. Include:
- Which of the flaws it is, named precisely — "the token is not bound to the session" rather than "CSRF protection is weak".
- The minimal modification. One request, one change, with the before and after. This is what a triager reproduces first.
- The token you used and where you got it. For the session-binding flaw, be explicit that it came from a different account you control — that is the whole point and it is easy to miss when skimming.
- A working HTML PoC for the browser-level attack, with the cookie's
SameSitevalue quoted from the response and the browsers you verified in. Generate it on the PoC page. - The impact of the action, not of CSRF in the abstract. See Methodology.
Related
A server-side token bound to the session, plus the lifecycle questions: per-request versus per-session, rotation, BREACH masking, and back-button breakage.
Naive double-submit and why OWASP now discourages it, then the HMAC-signed and encrypted-token variants that fix it.
GET and POST routing, _method and X-HTTP-Method-Override, and body parsers that ignore the declared content type.
Absent-header fallbacks, startsWith and contains matching, Origin: null from a sandboxed frame, and unsafe-url to restore a full Referer.
Enumerate, classify by auth mechanism, read the SameSite value first, then test — and write the finding with the browsers named.
Burp's CSRF PoC generator and where it produces a 2026-invalid PoC, ZAP, why curl proves nothing, and browser devtools.