skip to content

Your claim upload is rejected by the built-in CSRF token filter - why does answering 401 Unauthorized instead of 403 Forbidden mislead the client?

level: juniorimportance: must knowfreq 58%

answer

  1. two rejections, two different meanings
  2. the session cookie was fine
  3. provenance, not identity
  4. logging in again changes nothing
  5. 403 Forbidden, not 401 Unauthorized

basics

~20 s

A CSRF token mismatch is a provenance failure, not a credential one: the session cookie was valid, so 403 Forbidden fits. A 401 Unauthorized tells the client its credential is bad, so it discards a good session and forces a needless login.

solid answer

~50 s

The filter rejected the submission because it could not tie it to a form this server issued. The session cookie was perfectly good; only the request's provenance was in doubt. `403 Forbidden` says exactly that - the server understood the request and refuses to act on it, and repeating it with the same credential will not help. `401 Unauthorized` says something else: the credential is missing or unacceptable. Client code takes that literally. A shared response handler clears the stored session, drops the claimant on a login screen, and they re-authenticate straight back into the same failure, because nothing about their identity was ever wrong - and the open page, with the photographs they attached, is gone. Return 403, carry a machine-readable reason so the page can tell an absent token from a stale one, and re-render the form with a usable value.

code

http · 5 lines
http
HTTP/1.1 403 Forbidden
Content-Type: application/json
Cache-Control: no-store

{"error":"csrf_token_mismatch","detail":"The submitted form value did not match the one issued for this session."}

go deeper

for a junior

Remember the one-line split: 401 Unauthorized is about who you are, 403 Forbidden is about what this request is permitted to do. A rejected form token is the second.

for a middle

Explain the client-side consequence, not just the label. Describe how one shared response handler turns a wrong status into a cleared session, a login screen and a lost upload.

for a senior

Show that you design the rejection, not only the status: a reason code that separates absent, stale and mismatched values, a fresh token, no echo of the expected value, and a route back to the form.

for a principal

Frame it as an operational signal. A status chosen carelessly pollutes authentication telemetry across every service that emits it, so the estate needs one agreed meaning for 401 before any dashboard built on it can be trusted.

A built-in cross-site request forgery filter sits in front of unsafe requests and asks one question: was this request produced by a page this server rendered? It answers by comparing a value carried in the submission against a value the server holds for the session. When that comparison fails the filter has to say so with a status code, and that code is read by machines long before a human sees it. ## Two failures that are not interchangeable On one and the same request, two different things can go wrong. - **The credential is missing or unacceptable.** No session cookie arrived, or the one that arrived names a session the server no longer holds. The caller has not established who they are. - **The credential is fine and the provenance is not.** The session cookie arrived, it names a live session, the server is perfectly willing to say who the user is - but the value that should prove the submission came from a page this server rendered is absent, stale, or does not match what is stored. RFC 9110 keeps these apart. `401 Unauthorized` means the request lacks valid authentication credentials for the target resource, and the expected client reaction is to obtain credentials and try again. `403 Forbidden` means the server understood the request and refuses to authorise it, and that repeating the request with the same credential is not expected to succeed. A token mismatch is the second case in its purest form. ## Why the code you pick changes what the client does Most applications funnel every response through one place that reacts to authentication failures, because that is the only sane way to handle a session that really has ended. That shared reaction is what a wrong status recruits. | The client sees | What it concludes | What it does next | |---|---|---| | `401 Unauthorized` | this session is over | discards stored session state and routes to a login screen | | `403 Forbidden` | identity is fine, this act is refused | shows an error in place and leaves the session alone | The damage from the wrong choice compounds: 1. The claimant is thrown to a login screen although they were logged in the entire time. 2. They authenticate successfully, which proves nothing was wrong with their credential, and land on a dashboard rather than back on the form. 3. The upload is lost. A file control cannot be repopulated for them, so every photograph has to be chosen again. 4. Your telemetry now shows a spike in re-authentication that has no relationship to sessions expiring, which hides the actual defect for as long as anyone believes the graph. 5. If the client retries automatically after refreshing its session, it retries with the same stale value and fails identically, which turns one rejection into a loop. ## What the rejection response owes the caller besides the status A bare 403 is honest but not actionable, and 403 is also what a genuine authorisation failure returns, so the page cannot tell the two apart from the status alone. A useful rejection carries: - a **machine-readable reason code** that distinguishes an absent value from a stale one from a genuine mismatch, so the page can choose between re-rendering the form and reporting a permission problem; - a **fresh, usable value** for the next attempt, remembering that issuing one in this response does nothing for a page that is already open and still holds the old one; - **no echo of the expected value** in the body or in a diagnostic message, because that hands a forger exactly the thing the check exists to withhold; - a **path back to the form**, since the person on the other end has a claim to file and a refusal with no next step is where support tickets come from. ## Reading the rejection correctly in the field The first time most engineers meet this filter, they meet it rejecting one of their own requests, and the instinct is to reach for the login flow because the word that comes to mind is 'unauthorised'. The discipline is to read the two words the specification actually uses: unauthorised is about **who you are**, forbidden is about **what this request is allowed to do**. The token check never formed an opinion about identity. It formed an opinion about where the request came from, and it decided it could not tell. That is the sentence the status code has to carry.

  • The rejection response issues a fresh token. Why does the claimant's next attempt still fail?
    Because the fresh value lands in the rejection response, and the page holding the form was rendered earlier and still carries the old one. Resubmitting sends the stale value again and is rejected identically. The loop breaks only if the rejection path re-renders the form with the current value, or if the page reads the new value and updates its own field before resubmitting.
  • A genuine authorisation failure also returns 403 Forbidden. How should a page tell the two apart?
    Not from the status, which is the same in both cases. The rejection needs a machine-readable reason code in the body - a token failure and a missing role are different problems with different remedies, one re-rendering the form and the other telling the user they may not perform this action at all. Never distinguish them by parsing a human-readable message.
  • Is it acceptable to answer a token failure with 200 OK and an error object in the body?
    No. Caches, proxies, client error handling and monitoring all key off the status code, and a refused write reported as a success teaches every one of them that the write happened. The status has to carry the refusal; the body carries the reason.

Being turned back at a gate because the errand you arrived on could not be vouched for is not the same as being told your pass has expired. Sending you to the pass office to collect a new pass, which is what a login screen is, fixes nothing, because the pass was never the problem.

saying these in an interview costs you the question

  • Says any rejected request should be answered with 401 Unauthorized
  • Treats a token mismatch as evidence that the login expired
  • Claims re-authenticating will make the same submission succeed
  • Reads 403 as 'the user lacks a role' and escalates permissions
  • Answers the refusal with 200 OK and an error object in the body
  • Puts the value the check expected into the error message