CodeGym /Kursy /Spring Security /formLogin () and POST /logout in tests

formLogin () and POST /logout in tests

Spring Security
Poziom 27, Lekcja 1
Dostępny

1. The role of login/logout tests

If you have just learned how to write tests with .with(user("maria").roles("USER")), a natural thought arises: “Well, security is tested, time to celebrate.” But there is a catch. Such a test checks authorization rules (who gets access where), but almost does not check the authentication flow (how a person becomes logged in at all). And these are two different parts of the system — like “an access badge for a factory” and “checking a passport at the entrance”.

The most dangerous scenario for a project looks like this: you have green tests for /api/me and /api/admin/users, because you use user() everywhere — and it honestly creates an authenticated SecurityContext right inside the test request. But in reality your formLogin is broken: wrong PasswordEncoder, wrong UserDetailsService, incorrect login URL, you changed the username parameter to email and forgot about it, or someone “optimized” the config so that UsernamePasswordAuthenticationFilter no longer fires. As a result, a real user in the browser cannot sign in... while the tests smile happily and pretend everything is fine.

So the logic is simple: user()-tests are fast, high-volume checks of the access matrix, while formLogin() and logout tests are a few targeted but very important end-to-end checks that guarantee your stateful login/logout really works.

2. formLogin() in spring-security-test

When a beginner first tries to test login manually, they usually do post("/login"), put in the username and password parameters, and... get 403 Forbidden. Then the classic begins: “Spring Security is broken,” “CSRF is annoying,” “let's disable CSRF,” “I saw on the internet .csrf().disable().” And at that moment the teacher sighs, while somewhere in the world one secure backend cries.

formLogin() is a ready-made builder from spring-security-test that assembles a correct form-based login request for you. It does what the browser normally does with an HTML form: sends POST to the login processing URL, adds login and password parameters, sets the right Content-Type, and — most importantly in our context — adds a valid CSRF token, because login is a state-changing operation.

It is useful to keep in mind that formLogin() is not a “magic login me button,” but just a convenient way not to write the same thing by hand. In essence, it imitates the following request:

What Typical value
HTTP method POST
URL /login (default)
Content-Type application/x-www-form-urlencoded
Login parameter username=... (default)
Password parameter password=... (default)
CSRF present (otherwise you get
403
)

If you changed the formLogin settings (URL or parameter names), then the test must change along with them — we will talk about that a little later. For now, our goal is to learn how to test a successful and an unsuccessful login without turning the test into 30 lines of manual request building.

3. The test skeleton with MockMvc

Before writing login/logout tests, it is important that we have a MockMvc instance that really goes through the SecurityFilterChain. You should already have this skeleton from the previous day, but I will show it again briefly — just so the code in your head does not hang in the air like a bug in production on Friday evening.

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest // Start a real Spring context so SecurityFilterChain is real
@AutoConfigureMockMvc // Ask Spring to configure MockMvc on top of the filters (including Spring Security)
class AuthFlowTest {

    @Autowired
    MockMvc mvc; // MockMvc will run requests through the entire filter chain, not "around" it
}

This is the basic form: we start the application context and get MockMvc. Next, it is important to remember another practical thing: formLogin() triggers real authentication, which means your application in the test profile needs a real user in the user store (in-memory or DB). Unlike user(), here “the user in the test's head” will not do — Spring Security will honestly look for it through UserDetailsService.

If you do not have a prepared user maria with password secret in your tests, then formLogin().user("maria").password("secret") will not be a “green test,” but rather “a nice way to say BadCredentials.”

4. Successful login in tests

A successful login in a stateful model is not “we got 200 OK.” It is a transition of the system from the “anonymous” state to the “authenticated” state, plus creation/use of a session, plus standard browser-oriented behavior (usually a redirect). And we want to capture exactly that behavior in the test, not just the fact that the server responded with something.

The most compact and useful successful login test usually checks three things: status (most likely 3xx), redirect URL (if you expect it), and the fact of authentication (through the authenticated() matcher). Here is an example:

import org.junit.jupiter.api.Test; 

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

class AuthFlowTest {

    // mvc is already injected through @Autowired

    @Test
    void loginWhenValidCredentialsThenRedirectAndAuthenticated() throws Exception {
        // formLogin() imitates browser form submission (including CSRF), not "login from the test's head"
        mvc.perform(formLogin().user("maria").password("secret"))
                .andExpect(status().is3xxRedirection()) // For form login we usually expect a redirect, not 200
                .andExpect(redirectedUrl("/")) // Typical default behavior: after login, go to the home page
                .andExpect(authenticated().withUsername("maria")); // Record the fact of authentication in SecurityContext
    }
}

Why is authenticated() important here? Because it is a Spring Security-level check, not just “well, there seems to be a redirect.” In reality, you can get a redirect even after failed authentication (for example, to /login?error). So it is good when the test explicitly states: “the user really became authenticated.”

If you want to make the test a bit more “physical” (and this is useful for beginners), you can additionally check that a session appeared and that you can now access a protected endpoint with it. The point is not to turn the test into a movie trilogy, but one short “login → access /api/me” scenario gives a very clear picture.

import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MvcResult;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void meEndpointWhenLoginThenOk() throws Exception {
    // Step 1: log in "for real" so Spring Security creates a session for us
    MvcResult login = mvc.perform(formLogin().user("maria").password("secret"))
            .andReturn();

    // Grab the session from the login request — this is our "JSESSIONID in test form"
    MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false);

    // Step 2: go to a protected endpoint with the same session (as a browser would)
    mvc.perform(get("/api/me").session(session))
            .andExpect(status().isOk());
}

This example has a pleasant “real-life” truth to it: we logged in, got a session, and now /api/me is accessible because the server recognizes us by JSESSIONID (in the test, this is abstracted by MockHttpSession). Such a test is especially useful when the student does not yet fully believe that “session is a real thing, not a myth from the legends of ancient servlets.”

5. Unsuccessful login in tests

An unsuccessful login is not just “well, it happens.” In security, it is a mandatory scenario. And it matters not only for security, but also for diagnostics: if you accidentally break the PasswordEncoder, the first thing that usually breaks is the unsuccessful authentication path (and that is good, because you learn about it from a test, not from a user in Telegram).

In stateful form login, by default an unsuccessful login usually ends with a redirect to /login?error. And that is perfectly normal semantics for a browser flow. Our test should check: status 3xx, correct redirect, and that authentication did not happen (unauthenticated()).

import org.junit.jupiter.api.Test; 

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void loginWhenWrongPasswordThenRedirectToErrorAndUnauthenticated() throws Exception {
    // We care specifically about the "unsuccessful" authentication path: the password is wrong
    mvc.perform(formLogin().user("maria").password("wrong"))
            .andExpect(status().is3xxRedirection()) // In a browser scenario this is usually a redirect
            .andExpect(redirectedUrl("/login?error")) // Default error URL for form login
            .andExpect(unauthenticated()); // Explicitly record that SecurityContext did not become authenticated
}

Pay attention to an important psychological point: here we are not checking business endpoints, not sending a request to /api/me, not trying to “test everything in one test.” This test checks only the behavior of the login flow with a wrong password. The shorter and more unambiguous it is, the easier it is to maintain.

Sometimes it is useful to add one more negative scenario: the user does not exist. In terms of behavior for the client, it usually looks the same (for security reasons you do not want to say “that user does not exist,” otherwise you get user enumeration). But for tests, this is useful as a separate branch so that you do not confuse “user not found” with “password does not match.”

Manual POST /login without CSRF

Now there will be a small demonstration that is very good at curing the desire to disable CSRF “because the tests are red.” If you try to do login manually through MockMvc but forget CSRF, you will get 403 Forbidden. And this is not a Spring error; this is Spring trying to save you from bad decisions.

A test like this (or an experiment) usually ends with 403:

import org.junit.jupiter.api.Test; // Test annotation
import org.springframework.http.MediaType;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void loginWhenManualPostWithoutCsrfThenForbidden() throws Exception {
    // Here we deliberately do "as if form", but without csrf() — to see 403
    mvc.perform(post("/login")
            .contentType(MediaType.APPLICATION_FORM_URLENCODED) // Typical HTML form Content-Type
            .param("username", "maria") // Login form parameters
            .param("password", "secret")) // Password form parameters
            .andExpect(status().isForbidden()); // CSRF protected you from "convenience"
}

And here is the most important thing: formLogin() is not just “more convenient,” it also helps you not forget important protocol details. So in form login tests, it is almost always reasonable to use formLogin() instead of assembling the request manually, unless you are specifically testing “what happens without CSRF.”

Logout as a security event

With logout, beginners often have two myths. The first: “logout is when we delete a cookie on the frontend.” The second: “logout is not as important as login.” Both myths in the real world quickly turn into very strange bugs, when the user “logged out” but a minute later still remains logged in because the session on the server never went away.

In a session-based model, logout is an operation that changes the security state: Spring Security should clear the SecurityContext, usually invalidate the session, and often ask the browser to delete the cookie associated with the session (JSESSIONID). That is why logout is a state-changing request, and therefore, with CSRF enabled, it must be protected by a CSRF token.

At the HTTP level, in a typical Spring Security configuration, logout looks like POST /logout with CSRF. And the response is most often a redirect, for example to /login?logout (this is a standard marker that is convenient to show on the login page). And if you are testing the stateful branch of a project, then the logout test should be in your regression suite just as obligatorily as the login test.

6. Logout: basic tests

Let's start with the simplest thing: a logout request from an authenticated user with CSRF should pass and return the expected browser semantics (usually a redirect). We do this explicitly via POST /logout and add csrf().

import org.junit.jupiter.api.Test; // Test annotation

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void logoutWhenAuthenticatedAndCsrfThenRedirect() throws Exception {
    mvc.perform(post("/logout")
            .with(user("maria").roles("USER")) // Specify the authenticated user for the request
            .with(csrf())) // Logout is a state-changing operation, so CSRF is required
            .andExpect(status().is3xxRedirection())
            .andExpect(redirectedUrl("/login?logout")); // Typical marker of a successful logout
}

This test is useful, but it has one drawback: we did not make sure that logout really “closed the door.” In the end, one can return a redirect even with broken logic (a bad idea, but technically possible).

So the next level is a very short “login → access is available → logout → access is gone” scenario. Yes, this is already a slightly more integration-style test, but it clearly shows that the session really stops working.

import org.junit.jupiter.api.Test; // Test annotation
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MvcResult;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void meEndpointAfterLogoutShouldNotBeAccessible() throws Exception {
    // Step 1: log in and get a "live" session
    MvcResult login = mvc.perform(formLogin().user("maria").password("secret"))
            .andReturn();

    MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false); // Take the current session

    // Step 2: verify that access exists before logout
    mvc.perform(get("/api/me").session(session))
            .andExpect(status().isOk());

    // Step 3: perform logout within the same session + CSRF (as the browser would)
    mvc.perform(post("/logout").session(session).with(csrf()))
            .andExpect(status().is3xxRedirection());

    // Step 4: make sure the same session no longer gives access to the protected resource
    mvc.perform(get("/api/me").session(session))
            .andExpect(status().is3xxRedirection()); // usually redirect to /login
}

Yes, there are a few more lines than the “ideal 8.” But the student gets a complete picture in their head: the session lives while we are logged in; logout resets the state; after that, the same session no longer grants access. This is exactly the kind of test that saves you from “I think I added logout, but it does nothing.”

7. Customizing the login flow

Almost inevitably, a moment comes when you stop living in the “default world,” and the login flow becomes custom. For example, you change the login processing URL (not /login, but something like /api/auth/login-form), or decide that you log in by email, and then the form parameter is no longer username.

And here there is an important rule of maturity: if you changed the login flow contract, the tests must change together with the contract. This is not “tests get in the way of development”; tests record the reality you create yourself.

Example: you kept form login, but changed the login processing URL. Then in the test you must specify the URL when building the request:

import org.junit.jupiter.api.Test; // Test annotation

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void loginWhenCustomLoginUrlThenStillWorks() throws Exception {
    // If you changed loginProcessingUrl in the config, the test must go through the new URL
    mvc.perform(formLogin("/api/auth/login-form")
            .user("maria")
            .password("secret"))
            .andExpect(status().is3xxRedirection()); // At least verify the fact of successful login
}

And if you changed the name of the login parameter, that is reflected too:

import org.junit.jupiter.api.Test; // Test annotation

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void loginWhenEmailParameterThenOk() throws Exception {
    // userParameter(...) must match what you actually expect in the authentication filter
    mvc.perform(formLogin()
            .userParameter("email") // Now we log in not by username, but by email
            .user("maria@example.com")
            .password("secret"))
            .andExpect(status().is3xxRedirection()); // At the first steps, just confirm that it authenticates
}

Here I am deliberately not checking the redirect URL — because with customization you may have changed the success handler as well. At the first steps, it is more useful to record “it authenticates at all,” and only then make the test stricter.

And one more practical tip: it is better to customize gradually. First change the URL — fix the test. Then change the parameter — fix the test. And only after everything is green, start inventing a beautiful architecture of “our login flow is like in a big product.” Otherwise you will get a product like in a big product, but somehow without login.

8. Typical mistakes with formLogin() and logout

Mistake #1: trying to test login through user() and calling it “login testing.”
user() is a great tool for checking access rules, but it does not verify that your DaoAuthenticationProvider works, that the password is encoded correctly, or that your UserDetailsService really finds the user. If you do not add at least a couple of tests for formLogin(), you are effectively not testing the login flow.

Mistake #2: expecting 200 OK from a successful form login.
In a browser-oriented scenario, a successful login usually ends with 302 and a redirect. If you expect 200 in the test, then either you are testing the wrong thing, or you accidentally hit different behavior (for example, a custom success handler). It is better to start with expecting 3xx and authenticated(), and then refine the behavior for your specific contract.

Mistake #3: forgetting that formLogin() requires a real user in the user store.
Tests with @WithMockUser work perfectly even without a database and without in-memory users. But formLogin() does not. It authenticates honestly. Therefore, you either need a prepared user in the test database/seed data, or a separate test configuration of the user store, or you run these tests at the appropriate checkpoint of the project.

Mistake #4: testing logout as GET /logout or as POST without CSRF.
With CSRF enabled, logout must be a state-changing request with a token. If there is no token, getting 403 is normal. And that is not “getting in the way of development,” that is security doing its job. In tests, it is better to be explicit: post("/logout").with(csrf()), so you do not catch accidental behavioral differences between configurations.

Mistake #5: mixing login/logout and all business endpoints of the project in one test.
Sometimes you want to write one “giant” test: login, profile, drafts, publish, logout... Such a test will fail “for no apparent reason” and become your personal series for 12 seasons. It is better to keep login/logout tests separate and short, and check business endpoints with user()-based approaches — that way the test suite stays maintainable and predictable.

1
Zadanie
Spring Security, poziom 27, lekcja 1
Niedostępne
Formularz logowania z udanym i nieudanym logowaniem
Formularz logowania z udanym i nieudanym logowaniem
1
Zadanie
Spring Security, poziom 27, lekcja 1
Niedostępne
Logout i ponowne uzycie tej samej sesji
Logout i ponowne uzycie tej samej sesji
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION