1. CSRF in the project: operations map
When you hear “CSRF,” you immediately want to open SecurityFilterChain and start pulling .csrf(...) like levers in an airplane cockpit. But in a project, the right order is different: first we need to understand which operations actually change state, and only then celebrate that Spring Security checks the token. Otherwise, we risk making a “silent” state change where CSRF does not apply at all.
From the perspective of our project domain, state changes are fairly mundane: a user profile is updated, a draft text is changed, a draft is deleted, a draft moves from DRAFT to SUBMITTED. These are ordinary actions, and that is exactly where CSRF matters. The key idea is this: CSRF is attached not to a URI as a string, but to the meaning of the operation. And the simplest indicator of meaning is the HTTP method.
In our project, the “honesty” of the design looks like this: GET should be reading, PATCH/POST/DELETE should be changing. If we start deleting a draft via GET “for convenience,” we ourselves pull the foundation out from under the CSRF model. It is like fastening a seat belt to a chair with Velcro: it exists, but at the critical moment it turns into decoration.
2. Mini API audit: read and write
As a project grows, a typical trap appears for beginners: looking at endpoints one by one. As a result, CSRF is enabled, but “for some reason” the profile breaks, then drafts, then submit. The solution is simple and boring (and therefore correct): perform a short audit and explicitly split operations into read and write. And yes, this is one of those cases where a table is more useful than heroics.
Below is a minimal map for our current session-based model with form login. Here it is already useful to assemble the API as a whole into a read/write map instead of looking at one random endpoint:
| Area | Endpoint | Method | What happens in terms of meaning | Is CSRF checked? |
|---|---|---|---|---|
| public | /api/public/articles | GET | read published articles | no (safe method) |
| public | /api/public/articles/{slug} | GET | read an article | no |
| me | /api/me | GET | read “who am I” | no |
| me | /api/me/profile | GET | read profile | no |
| me | /api/me/profile | PATCH | update profile | yes |
| drafts | /api/drafts | GET | read list of drafts | no |
| drafts | /api/drafts | POST | create a draft | yes |
| drafts | /api/drafts/{id} | GET | read one draft | no |
| drafts | /api/drafts/{id} | PATCH | update a draft | yes |
| drafts | /api/drafts/{id} | DELETE | delete a draft | yes |
| drafts | /api/drafts/{id}/submit | POST | move a draft to SUBMITTED | yes |
Notice one detail that the beginner brain sometimes sabotages: POST /api/drafts/{id}/submit is not “some kind of internal POST”, but a real state change. We are changing the status of a domain object, which means we are changing the application state. And CSRF is needed here for exactly the same reason as for PATCH or DELETE.
And conversely: GET /api/drafts/{id} can be very “sensitive” from a privacy standpoint (you cannot read someone else’s draft), but this is not a CSRF case, because it does not change state. That is a different security axis (authorization), and we do not mix it with CSRF.
3. Code: controllers and the CSRF filter
Controllers: honest HTTP methods
Now we land in the code. The important idea here is: CSRF checking should not live in the controller. The controller should be honest in terms of HTTP and domain behavior, while CSRF calmly checks in CsrfFilter before we even reach @RestController.
Let’s start with the profile. We need the same resource to be readable and editable with different methods.
package com.example.securecontent.profile;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/me/profile")
public class ProfileController {
@GetMapping
public ProfileDto get() {
// Reading state: CSRF is not needed here (safe method).
return new ProfileDto("Neo", "I know CSRF"); // demo
}
@PatchMapping
public ProfileDto update(@RequestBody UpdateProfileRequest request) {
// State change: CSRF is required, the filter will check the token BEFORE it reaches here.
return new ProfileDto(request.displayName(), request.bio());
}
}
And DTOs in a format that does not scare a beginner (and works very well with JSON):
package com.example.securecontent.profile;
// record matches the shape of a JSON object nicely: fields -> keys, constructor -> required values.
public record UpdateProfileRequest(String displayName, String bio) { }
public record ProfileDto(String displayName, String bio) { }
Drafts follow the same logic, only with more varied operations. The important part is not to try to “save URLs” and turn everything into POST /api/drafts/doSomething.
package com.example.securecontent.content;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/drafts")
public class DraftController {
@PatchMapping("/{id}")
public void update(@PathVariable long id, @RequestBody UpdateDraftRequest req) {
// State change: we update the draft fields (title/body).
// Important: no "convenient" GET operations for update.
}
@DeleteMapping("/{id}")
public void delete(@PathVariable long id) {
// State change: we delete the draft.
// If this were GET, in the default Spring Security configuration
// the CSRF check would not run, because the framework assumes
// GET remains a read operation.
}
}
DTO for updating a draft can also stay compact:
package com.example.securecontent.content;
// Minimal contract for PATCH: only what we really update.
public record UpdateDraftRequest(String title, String body) { }
The most important thing in this section is not the methods themselves, but the discipline: GET must not change state. If you ever add a “convenient” endpoint @GetMapping("/{id}/delete"), you will not just break REST etiquette. You will create a hole where the CSRF mechanism in the default Spring Security configuration will not trigger, because for the framework GET is a safe method.
Where CSRF triggers in the chain
People often confuse CSRF with “validation in the controller,” because a beginner thinks of controllers as the “entry point to the system.” But in Spring Security, the entry point is the filters. That means CSRF is checked before the controller, roughly where we previously saw SecurityContext restoration.
If we draw it in a very simplified way, we get this diagram:
flowchart TD
A["Browser"] -->|JSESSIONID cookie| B["Security Filter Chain"]
A -->|X-CSRF-TOKEN header| B
B --> C["CsrfFilter: compare expected vs actual"]
C -->|OK| D["Authorization filter"]
D -->|OK| E["DispatcherServlet"]
E --> F["@RestController"]
C -->|Fail| X["403 (controller not executed)"]
By default, Spring Security performs this check exactly on unsafe methods and skips GET, HEAD, TRACE, and OPTIONS. So honest HTTP methods here are not a formality, but the foundation of the security model.
An important “psychological” benefit of this diagram is that it explains why, when CSRF fails, you might not see logs from your controller/service. They simply never run. And that is normal: the protective layer has already fired.
Another subtle point: CSRF and authorization do not compete. They answer different questions. Authorization answers “is this user allowed.” CSRF answers “does this request look like it came from a legitimate browser scenario, rather than an attacker’s page.” If the answer to either of these questions is “no,” the request must die before business code. Sometimes that looks harsh, but it is predictable.
4. Practice: PATCH profile with a token
Now let’s do what this lecture is really for: take one endpoint that changes state and look at it without mysticism. The perfect test subject is PATCH /api/me/profile, because it is very realistic: the user changes displayName and bio.
Imagine a typical session-based application scenario: the app is running, formLogin is enabled, the user is logged in, and a JSESSIONID cookie appears in the browser. Now we want to make a PATCH request. For that we need two things: preserve the session (cookie) and send the CSRF token.
For hands-on debugging, it is convenient to have a temporary local-only debug endpoint. In the product flow the browser does not need this: it assembles the form and token itself. But for curl or Postman, it is useful to temporarily extend the familiar debug endpoint and add the token value itself. This is not a product “feature,” but a tool for seeing the token + session connection manually. Such an endpoint is best kept under the local profile or hidden under /debug/**.
package com.example.securecontent.security.debug;
import java.util.Map;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CsrfDebugController {
@GetMapping("/debug/csrf")
Map<String, String> csrf(CsrfToken token) {
// local-only: for manual diagnostics we show not only the names,
// but also the token value itself. This is not part of the product API.
return Map.of(
"headerName", token.getHeaderName(),
"parameterName", token.getParameterName(),
"token", token.getToken()
);
}
}
Now the manual scenario looks like this (and yes, this is the rare moment when curl helps you understand Spring Security faster than 40 minutes of philosophy):
# 1) Log in through /login in the browser (create a session)
# 2) In the same browser, open local-only /debug/csrf and copy the token from JSON.
# At the same time, in DevTools, look at the JSESSIONID cookie and copy its value.
# 3) Make a PATCH with cookie + CSRF header
curl -i \
-H "Content-Type: application/json" \
-H "X-CSRF-TOKEN: PASTE_TOKEN_HERE" \
-H "Cookie: JSESSIONID=PASTE_SESSION_ID_HERE" \
-X PATCH \
-d '{"displayName":"Trinity","bio":"PATCH is my cardio"}' \
http://localhost:8080/api/me/profile
If you remove the X-CSRF-TOKEN header but keep the cookie, you will see a “bad” rejection. And that is very revealing: the server recognized the user by the session cookie, but did not receive confirmation of a correct browser flow. That is exactly the meaning of CSRF.
And if you remove the cookie but keep the token, it will also be rejected, because in our current model the token lives in HttpSession, and without the session context it becomes just a random string. The CSRF token does not “replace” authentication; it complements it.
5. Drafts: PATCH, DELETE, submit
After the profile, drafts usually “blow the beginner’s mind” for two reasons. First, there are more of them. Second, they contain operations that look like a workflow (submit), and people want to treat them as an exception to the rules. In reality, everything here is boring and wonderful: if an operation changes domain state, CSRF applies to it exactly as it does to update/delete.
Let’s take draft update:
# PATCH changes state -> CSRF token + the same session (cookie) are required
curl -i \
-H "Content-Type: application/json" \
-H "X-CSRF-TOKEN: PASTE_TOKEN_HERE" \
-H "Cookie: JSESSIONID=PASTE_SESSION_ID_HERE" \
-X PATCH \
-d '{"title":"New title","body":"New body"}' \
http://localhost:8080/api/drafts/10
Deleting a draft is the same, just without a body:
# DELETE changes state -> also requires a CSRF token
curl -i \
-H "X-CSRF-TOKEN: PASTE_TOKEN_HERE" \
-H "Cookie: JSESSIONID=PASTE_SESSION_ID_HERE" \
-X DELETE \
http://localhost:8080/api/drafts/10
And now submit. It is very tempting to make it “like a link,” because “it is just an action.” But from a security perspective, submit is a state change, which means it must remain an unsafe method.
# submit changes the status of a domain object -> this is also a state change
curl -i \
-H "X-CSRF-TOKEN: PASTE_TOKEN_HERE" \
-H "Cookie: JSESSIONID=PASTE_SESSION_ID_HERE" \
-X POST \
http://localhost:8080/api/drafts/10/submit
If all of this works with a token and not without one, then you have done an important thing: you have verified that CSRF in the project is not mystical Spring magic, but a predictable rule: “unsafe methods require additional proof of a correct scenario.”
6. Design rules for CSRF
In an educational project, it is especially easy to “accidentally break” the CSRF model, because you want to see results quickly instead of maintaining API design discipline. But CSRF actually loves discipline: it is built on the assumption that safe methods remain safe. So we need simple, almost everyday rules that keep the project in a sane state.
It is useful to think not in terms of Spring Security settings, but as this “bad/good” design pair (and yes, it is boring — which means it will work):
| Task | Bad option | Good option |
|---|---|---|
| Delete a draft | GET /api/drafts/{id}/delete | DELETE /api/drafts/{id} |
| Submit for moderation | GET /api/drafts/{id}/submit | POST /api/drafts/{id}/submit |
| Update profile | POST /api/me/profile/update | PATCH /api/me/profile |
In the good options, CSRF protection “stands up” naturally. Spring Security and the browser model help you because you are not going against the meaning of HTTP. In the bad options, you yourself remove the signals that the protection relies on. And then the folklore begins: “Spring Security is acting weird again.” No, it is not Spring. It is us.
And here is the equally important discipline of thought: request-level authorization and CSRF are two layers. Even if PATCH /api/drafts/{id} is available only to an authenticated user, CSRF is still needed, because it answers a different question. Not “can it,” but “did this user really press the button themselves, or did their browser obediently execute someone else’s command.”
7. Typical mistakes with CSRF
In this topic, it is easy to fall into two extremes: either “I get it, CSRF is just a token” (and then hit a wall on the first 403), or “forget it, I’ll disable it and keep going” (and build a very dangerous habit in your head). That is why it is useful to talk through mistakes not as horror stories, but as normal, almost inevitable pitfalls.
Mistake #1: hiding a state change behind GET because “it is more convenient to click a link.”
More convenient — yes, but only until you realize that the CSRF protection does not trigger by definition, because GET in the HTTP model must be safe. In our project, delete and submit must remain DELETE and POST, even if that seems like “extra complexity.”
Mistake #2: thinking that CSRF is tied to a URI, not to an operation.
A beginner sees /api/me/profile and thinks that “this URL is either CSRF-relevant or it is not.” In practice, CSRF applies to PATCH /api/me/profile, but not to GET /api/me/profile. It is the same URI, but two different operations. If you accept that, half the confusion disappears.
Mistake #3: looking at endpoints one by one instead of as a map of read/write actions.
When you have five controllers and twenty methods, the brain starts to “optimize” and forgets about submit, delete, and update. A simple audit table (like the one in the middle of the lecture) saves nerves: it is clear where state changes happen, and therefore where CSRF must exist.
Mistake #4: confusing a CSRF rejection with a role or login problem.
A CSRF failure can look like a 403, and that automatically makes you think “I am missing a role.” But CSRF is not about a role. It is about the request not confirming a correct scenario. That is why it is important to first check the session cookie and token, and only then dive into hasRole(...).
Mistake #5: accidentally “losing the session,” and then wondering why the token “suddenly stopped working.”
In our baseline, the token lives in HttpSession. If the session changed or expired, the expected value disappears, and the next state-changing request fails. It is useful to remember here: the CSRF token and the session are a pair, not independent entities.
GO TO FULL VERSION