1. The role of jwt() in MockMvc
If you have ever tried writing tests “for real” — generating an access token, signing it with a secret, stuffing it into Authorization: Bearer ... and hoping everything matches the current configuration — you already know this feels like trying to test a door lock by casting a new key from metal every time. It seems honest, but it quickly becomes painful, slow, and brittle.
In security tests, it is very important to separate two tasks. The first task is to make sure that access rules work: the editor endpoint is really available only to the editor, the admin endpoint is really available only to the admin, and /api/me does not open for an anonymous request. The second task is to make sure that the token is actually valid: the signature is correct, exp has not expired, and the format is not broken. These are different types of checks, and if you mix them in one style of test, you end up with a test suite that is either slow, brittle, or both.
jwt() in spring-security-test gives us a “test pass” (like a conference badge): it lets us simulate an already authenticated request with a JWT context so that you test authorization and business constraints specifically.
2. What jwt() does and where it stops
To keep jwt() from being perceived as a “magic sticker,” it helps to keep a picture of the real runtime in mind. In production, a request comes in with the Authorization header, then somewhere in the filter chain the bearer token is extracted, then validated (signature/TTL/claims), then an Authentication object is created, and only after that do your authorizeHttpRequests, @PreAuthorize, and other “permission checks” kick in.
In a MockMvc test with jwt(), we deliberately shorten the path. It is as if we tell Spring Security: “pretend the bearer token has already been parsed and is valid, here is a ready-made Jwt, here are the authorities, continue from here.” And then we verify that access rules and business constraints behave exactly as intended.
flowchart TD
A["MockMvc request"] --> B["SecurityFilterChain"]
subgraph "Runtime (real request)"
B --> C["Bearer token parsing"]
C --> D["JwtDecoder / custom validation"]
D --> E["Authentication created"]
end
subgraph "MockMvc + jwt() (test request)"
B --> E
end
E --> F["authorizeHttpRequests / @PreAuthorize"]
F --> G["Controller / Service"]
This is the main point: jwt() helps test authorization decisions, but it is not suitable for testing “broken tokens,” “unparseable tokens,” “invalid signatures,” and other situations where the parsing and validation path of the bearer token itself matters.
3. The basic happy path with jwt()
When a project already has a stateless JWT branch, the most basic test worth writing sounds almost boring: “A JWT user can call a protected endpoint.” But this is the right kind of boring — it captures the baseline. If such a test suddenly stops passing after your configuration changes, your entire stateless flow is broken.
Below is an example where we simply verify that /api/me is available to an authenticated request with a JWT context:
import org.junit.jupiter.api.Test;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void meWhenJwtThenOk() throws Exception {
mvc.perform(
// jwt() does NOT add an Authorization header,
// it sets up the SecurityContext with an already "ready" JWT-authenticated user
get("/api/me").with(jwt())
)
// We are checking the access rule itself: the endpoint requires authentication
.andExpect(status().isOk());
}
There are two important things to understand here. First, jwt() does not “add a header”; it adds a security context. Second, this is not a token test, but a test of the rule “this endpoint requires authentication.” If you want to check role/permission rules, you need to add authorities, and that is the next step.
4. Authorities: roles and permissions
As soon as you move from “just authenticated” to “different parts of the project,” you need to learn how to set authorities correctly. And this is where beginners usually hit the classic moment: “I gave the EDITOR role, but access is still denied.” The reason is almost always the same — confusion between role and authority, plus a forgotten ROLE_ prefix.
If in your configuration or in @PreAuthorize you use hasRole("EDITOR"), then under the hood this is a check for the ROLE_EDITOR authority. That is, at the GrantedAuthority level, the real string will include ROLE_. In tests via jwt(), you usually set authorities directly, so the string must be exact.
Example: the endpoint /api/editor/review-queue is available only to the editor. In a test it looks like this:
import org.junit.jupiter.api.Test;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void reviewQueueWhenEditorRoleThenOk() throws Exception {
mvc.perform(get("/api/editor/review-queue")
// IMPORTANT: hasRole("EDITOR") expects the authority "ROLE_EDITOR" under the hood
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_EDITOR"))))
// If the authority is set correctly, access should be granted
.andExpect(status().isOk());
}
Notice that we do not write .roles("EDITOR"), because that is a convenient API for user(). With jwt(), you usually work with authority strings directly.
Now about “permissions.” In our project, we have an authority model like draft:review, user:manage, etc. If a rule checks authority through hasAuthority("draft:review"), then you must provide exactly that string, without ROLE_.
Example, if an endpoint or method is protected by the draft:review permission:
import org.junit.jupiter.api.Test;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void reviewQueueWhenDraftReviewAuthorityThenOk() throws Exception {
mvc.perform(get("/api/editor/review-queue")
// No "ROLE_" needed here: the check goes through hasAuthority("draft:review")
.with(jwt().authorities(new SimpleGrantedAuthority("draft:review"))))
.andExpect(status().isOk());
}
Yes, it looks a little stringy, but it is an honest reflection of how Spring Security makes decisions: it compares authority strings. That is why careful naming of permissions in the project is not just “cosmetics,” but part of the security model’s functionality.
5. Claims: subject and custom fields
Almost every project eventually starts depending not only on roles, but also on which specific user it is. For example, /api/me usually returns the current user’s profile, and owner-based rules compare the object owner with the current principal. In the JWT world, “who you are” is most often expressed through the sub claim (subject). And this directly affects what will be in authentication.getName().
If you do not set the subject explicitly, jwt() creates some minimal default Jwt. For a “just 200 OK” test, that is fine. But as soon as you want stable and meaningful checks, it is better to set the subject explicitly.
Below is an example: we replace the subject with maria:
import org.junit.jupiter.api.Test;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void meWhenSubjectMariaThenOk() throws Exception {
mvc.perform(get("/api/me")
// Inject the required sub so the principal is predictable
.with(jwt().jwt(jwt -> jwt.subject("maria"))))
.andExpect(status().isOk());
}
If your /api/me endpoint returns the user name, you can make the test even more meaningful: you are already checking not only that access exists, but also that the user context is really the one you simulated. For example, if the response contains a username field, the test may look like this (assuming such a response contract):
import org.junit.jupiter.api.Test;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void meWhenMariaJwtThenReturnsMaria() throws Exception {
mvc.perform(get("/api/me")
// Here we check not only access, but also that "who I am" in the context is really maria
.with(jwt().jwt(j -> j.subject("maria"))))
.andExpect(status().isOk())
// Contract check: the application really reads the principal/claims and formats the response correctly
.andExpect(jsonPath("$.username").value("maria"));
}
Yes, jsonPath appears here, and the test becomes a bit more contract-oriented, but it starts catching a very real problem: when you are supposedly “authenticated,” but the principal is unexpectedly wrong, or the claim was placed in the wrong spot, or an error was made somewhere in JWT to principal conversion.
By the way, you can also add any custom claims that are actually used by your rules, just do not turn the test JWT into a “Santa Barbara: 300 episodes of claims” series. For example, if you need userId in the uid claim:
import org.junit.jupiter.api.Test;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void meWhenUidClaimPresentThenOk() throws Exception {
mvc.perform(get("/api/me")
// Add a custom claim if your code/rules really need it
.with(jwt().jwt(j -> j.claim("uid", 42L))))
.andExpect(status().isOk());
}
The point is not that the endpoint must read uid. The point is the approach: put into the test JWT only those claims that actually participate in the access decision or in shaping the response.
6. jwt() and your code: rules and principal
When you use jwt() in tests, you are testing the application roughly in the form in which it lives in the stateless branch: the current user comes “from the token.” That is why jwt() works well both with request-level rules (authorizeHttpRequests) and with method-level rules (@PreAuthorize), because both levels make decisions based on the Authentication and its authorities.
Besides access control, jwt() is also useful as “fuel” for the places where you read the current user in code. The most straightforward option in a stateless branch is to read the Jwt as the principal via @AuthenticationPrincipal. This gives predictable claims and reduces the amount of homemade “pull something out of SecurityContextHolder.”
Mini-example of a controller (a fragment, not the whole class) that returns the user subject:
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
@GetMapping("/api/me")
MeResponse me(
// In a stateless JWT scenario, the principal is often Jwt, not "UserDetails"
@AuthenticationPrincipal Jwt jwt
) {
// Take "who I am" from sub (subject)
return new MeResponse(jwt.getSubject());
}
Now you understand why in tests we were so persistent about setting sub: otherwise you are testing “some default user,” not the real actor in your system.
Another useful thought: @WithMockUser and .with(user("...")) create an Authentication of a different type (usually UsernamePasswordAuthenticationToken). This is convenient and fast, but if your code expects a Jwt principal specifically (for example, in a method argument), then @WithMockUser may give you the “wrong” principal. In such cases, jwt() is not a whim, but a way to test exactly the contract you really use in the stateless branch.
7. Common mistakes when working with jwt() in MockMvc
Mistake #1: trying to use jwt() to test a “broken token.”
jwt() does not validate the real parsing of a bearer token string, does not validate the signature, and does not reproduce format errors. It models an already ready Jwt in the context. Therefore, tests like “what happens if the token is corrupt” must be built differently — through a real Authorization header and real bearer token processing in the filter chain (and that is a separate class of scenarios).
Mistake #2: forgetting that jwt() is for the stateless branch, not a universal key to every door.
If you are testing session-based behavior (formLogin, logout, CSRF), then jwt() will not help and will only confuse things. For the stateful branch, use formLogin(), user(), and csrf(). For the stateless branch — jwt() and/or bearer-header scenarios.
Mistake #3: confusing role and authority, then arguing with ROLE_ for half a day.
If somewhere you have hasRole("EDITOR"), then the authority must be ROLE_EDITOR. If somewhere you have hasAuthority("draft:review"), then it must be exactly that string. In tests this is especially noticeable because you set authorities by hand. The most common problem is writing EDITOR instead of ROLE_EDITOR and wondering why access is not granted.
Mistake #4: leaving the default subject and then “accidentally” not testing real owner rules.
When the subject remains default, you are checking access for an abstract user and can easily miss an error where the real username/userId does not reach the principal. As soon as the project gets “own/other” logic, the default subject turns into a ticking time bomb. It is better to set sub meaningfully from the start wherever it matters.
Mistake #5: forgetting to grant the authority that the rule really depends on in an access test.
Sometimes a test looks like this: with(jwt()), expectation 200, but the endpoint actually requires ROLE_ADMIN or draft:publish. Such a test will either fail and annoy you, or, if the endpoint accidentally became more open, it will pass and hide the hole. Before the test, it is useful to say the human formula out loud: “who is making the request, where, and which permission is needed.”
GO TO FULL VERSION