1. The combination of formLogin and the access matrix
The most common beginner trap sounds roughly like this: “Well, I added formLogin(), so now everything is protected ‘properly’.” Alas, that is about as useful as putting an intercom on the building entrance and declaring that all security problems in the apartment are solved. The intercom is only the entrance to the building. The access matrix is what determines which doors inside are open at all, and to whom.
formLogin answers the question “how do I become authenticated?” (that is, pass the login/password check and enter the authenticated state). The access matrix answers the question “what can I do after I have logged in?”. These two layers must work together, but not be mixed up: sign-in should not replace access rules, and access rules should not accidentally block sign-in and sign-out.
In our Secure Content Platform API project, this is especially easy to see. We have public articles, a personal area (/api/me/**), privileged editor areas (/api/editor/**), and an admin area (/api/admin/**). formLogin makes the path into these areas convenient for the browser, but the areas themselves still need to be controlled by strict URL rules.
2. Access zones and behavior without sign-in
Before writing the configuration, it is useful to pause for a minute and “put the policy on the table.” Spring Security is excellent at closing doors, but it cannot read minds. If you have not stated what you want to protect and how, you will get either something too closed (even public articles require a login) or too open (the admin area is available to anyone who is signed in).
Below is the minimal access matrix for this current stage of the course only (session/browser-friendly behavior, the built-in login page is acceptable). Note: we are deliberately staying within four zones and not overcomplicating the domain.
| Zone | Example endpoints | Who should have access | What happens in the browser without sign-in |
|---|---|---|---|
| Public | GET /api/public/articles, GET /api/public/articles/{slug} | Anyone, including anonymous users | 200 OK immediately, with no redirects |
| Me (personal area) | GET /api/me, (later /api/me/profile, /api/me/avatar) | Any authenticated user | redirect to /login |
| Editor | /api/editor/** | Only a user with the EDITOR role | redirect to /login (if anonymous), or 403 (if signed in but not an editor) |
| Admin | /api/admin/** | Only a user with the ADMIN role | redirect to /login (if anonymous), or 403 (if signed in but not an admin) |
| Sign-in/sign-out | /login, /logout | Must be reachable | sign-in must always be open; otherwise you have “locked the guard’s door” |
At this stage, we treat roles as independent. In other words, ADMIN does not automatically pass hasRole("EDITOR") until you explicitly grant both roles or configure a role hierarchy.
This same idea is easy to see as a small scenario graph:
flowchart TD
A["Anonymous"] -->|GET /api/public/articles| P["200 OK"]
A -->|GET /api/me| L["302 -> /login"]
L -->|"POST /login (credentials)"| S["Authenticated"]
S -->|GET /api/me| M["200 OK"]
S -->|"GET /api/admin/users (role USER)"| F["403 Forbidden"]
S -->|"GET /api/admin/users (role ADMIN)"| AD["200 OK"]
There is no JWT “magic” here, no complicated protocols. There is straightforward engineering logic: first sign-in, then rules.
3. SecurityFilterChain as an access map
As a project grows, security configuration usually dies in one of two ways. First, people start “fixing” it with one-off lines, and it turns into a layered pie of requestMatchers where nobody understands what is actually open. Second, people are afraid to touch it because they do not know what might break. The good news: at our current level, discipline and readability solve this.
The idea is simple: we write SecurityFilterChain so that it can be read top to bottom as a statement of access policy. First come sign-in/sign-out and public resources, then the personal area, then privileged zones, and at the very end a “safety fuse” called denyAll().
Below is an example of a complete configuration for our educational project. It is intentionally a bit more explicit than “the shortest thing that works”: we leave /error open, and we open sign-in and sign-out right next to the flows themselves via formLogin(...).permitAll() and logout(...).permitAll(). That way, the access map reads top to bottom, and login/logout do not get scattered across two places.
package com.example.securecontent.security.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// We explicitly declare the "access map" by URL: it reads top to bottom.
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/error").permitAll() // it is better not to lock down a system route forever
.requestMatchers("/api/public/**").permitAll() // public area
.requestMatchers("/api/me/**").authenticated() // personal area: any signed-in user
.requestMatchers("/api/editor/**").hasRole("EDITOR") // editor area: EDITOR role required
.requestMatchers("/api/admin/**").hasRole("ADMIN") // admin area: ADMIN role required
.anyRequest().denyAll() // safety fuse: everything else is closed
);
// formLogin only answers "how to sign in", not access rights to URLs.
http.formLogin(form -> form
.defaultSuccessUrl("/api/me", true) // after login, go to /api/me (handy for verification)
.failureUrl("/login?error") // make the error visible in the browser
.permitAll() // login page and login handling are available to anonymous users
);
// logout must also be reachable, otherwise you have "locked the door from the inside."
http.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
.permitAll() // open the logout flow next to logout itself
);
return http.build();
}
}
If you later change loginPage, loginProcessingUrl, or add resources for your login page, you can also open those paths explicitly through requestMatchers(...). But for the current baseline, it is simpler to keep sign-in/sign-out close to formLogin and logout: that reduces the chance of having two configuration spots drift apart.
There are several important takeaways here. First, formLogin does not “open” editor/admin access for you. It only provides a way to become an authenticated user. Second, hasRole("ADMIN") and hasRole("EDITOR") are already authorization, meaning access-right checks. Third, putting denyAll() at the end is a convenient way not to accidentally expose new endpoints that you add tomorrow and forget to protect. Spring Security already likes secure-by-default, but when you write the access map yourself, that safety net does not hurt.
Finally, a small practical note: allowing /error often saves nerves. If you completely lock everything down and do not allow the error route, you can sometimes get strange loops and non-obvious responses. This is not a “feature of the day,” just a calm baseline for an educational project.
4. Checking the matrix
Users for checking the matrix: USER, EDITOR, ADMIN
When we verify access, we need not abstract “a user with role X,” but concrete accounts that can be used to sign in through the browser and walk through the scenario by hand. In-memory users are ideal for this stage: they remove database noise and let us focus on the mechanics of formLogin and URL rules. They also quickly show where you have fooled yourself in the configuration.
Here is an example UserDetailsService for the current checkpoint. The password password is the same on purpose: we are learning the security mechanics, not training memory for “guess the editor’s password.”
package com.example.securecontent.security.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
public class InMemoryUsersConfig {
@Bean
UserDetailsService userDetailsService(PasswordEncoder encoder) {
// Three accounts — the minimum set for checking the whole matrix (user/editor/admin).
// The roles here are independent, with no hidden hierarchy.
UserDetails user = User.builder()
.username("user")
// We encode the password with the same PasswordEncoder that is configured in the application.
.password(encoder.encode("password"))
.roles("USER")
.build();
UserDetails editor = User.builder()
.username("editor")
.password(encoder.encode("password"))
.roles("EDITOR")
.build();
UserDetails admin = User.builder()
.username("admin")
.password(encoder.encode("password"))
.roles("ADMIN")
.build();
// InMemoryUserDetailsManager stores users in memory: convenient for manual verification in the browser.
return new InMemoryUserDetailsManager(user, editor, admin);
}
}
There is one thing worth saying out loud here, and remembering, because it will save you from “why 403?!” many times later: the roles("ADMIN") method adds a role in the ROLE_ADMIN format. That is why hasRole("ADMIN") expects the string without the prefix. If you write hasRole("ROLE_ADMIN"), you will get the surprising feeling that the role “exists, but does not work.” Spoiler: the role exists; you are just comparing different strings.
Minimal zone controllers
It is very easy to “configure security” and still not understand whether it works, because the endpoints do not show anything. At this point in the course, stub controllers that return simple text and, where appropriate, the current user name are useful. This gives instant feedback: you do not only see “access exists,” but also “who exactly is currently considered the user.”
Example of a public endpoint:
package com.example.securecontent.content;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class PublicArticlesController {
@GetMapping("/api/public/articles")
String list() {
// Stub for checking permitAll(): it is important to see "200 OK" without a login.
return "public articles"; // stub: a real list will appear here later
}
}
Example of the personal area /api/me, where we obtain the current user through Authentication (this is the simplest “thermometer” for whether SecurityContext is actually populated):
package com.example.securecontent.profile;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MeController {
@GetMapping("/api/me")
String me(Authentication authentication) {
// Authentication comes from SecurityContext: if you are not logged in, security will not let you in here.
return "current user: " + authentication.getName();
}
}
Editor area (again, primitive, but illustrative):
package com.example.securecontent.content;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class EditorController {
@GetMapping("/api/editor/review-queue")
String queue(Authentication authentication) {
// This endpoint should work only for the EDITOR role.
return "review queue for editor: " + authentication.getName();
}
}
Admin area:
package com.example.securecontent.admin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AdminController {
@GetMapping("/api/admin/users")
String users() {
// A simple text response is enough here: what matters is that USER/EDITOR get 403 and ADMIN gets 200.
return "admin-only users list (stub)";
}
}
This is still one application and one domain. We are not making separate “demo controllers” just for the course: we are simply returning strings for now so that we can see the behavior. Later these endpoints will return proper DTOs, but today it is more important to see the security meaning than the beauty of JSON.
Checking the matrix in the browser: expected behavior
Testing through the browser is not “just messing around.” It is the fastest way for your brain to feel the difference between anonymous and authenticated states, and to see how formLogin really becomes the bridge to your protected endpoints. It is especially useful to test in incognito mode so that you do not get confused by saved state.
Below is a handy “what should happen” table if you run the application locally on the standard http://localhost:8080:
| Scenario | Who you are now | Where you go | Expectation | Why |
|---|---|---|---|---|
| Public reading | anonymous | GET /api/public/articles | 200 OK and the text public articles | this is permitAll() |
| Trying to enter the personal area without logging in | anonymous | GET /api/me | redirect to /login | the endpoint requires authenticated() |
| Successful sign-in | enter user/password | /login | after login, we land on /api/me | defaultSuccessUrl("/api/me", true) |
| Trying to enter the editor area as an ordinary user | logged in (user) | GET /api/editor/review-queue | 403 Forbidden | authentication exists, but the role does not |
| Sign in as the editor | enter editor/password | /login | after login, current user: editor | same flow, different principal |
| Editor area | logged in (editor) | GET /api/editor/review-queue | 200 OK | hasRole("EDITOR") |
| Editor trying to enter the admin area | logged in (editor) | GET /api/admin/users | 403 Forbidden | wrong role |
| Sign in as the administrator | enter admin/password | /login | after login, current user: admin | now the role is admin |
| Admin area | logged in (admin) | GET /api/admin/users | 200 OK | hasRole("ADMIN") |
An important point to state clearly: in this branch of behavior, redirects to /login are normal. We are intentionally using a browser-friendly model right now, because it best shows the mechanics. When you test the same API not in a browser, but, for example, in Postman, redirects and HTML may look unusual — but that is exactly the “browser semantics.” At this stage of the course, we care about how visible it is.
5. The risk of a broad authenticated() for /api/**
A developer’s brain loves simplification. And one of the most tempting simplifications sounds like this: “Well, let’s make everything under /api/** just authenticated() — done.” That really is convenient... right up until the first admin operation. After that, it turns out that yes, you are “protected,” but the protection is far too coarse: any signed-in user becomes almost an administrator simply because your URL pattern is too broad.
Here is the classic “almost correct” version that often appears in a project on emotion:
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/**").authenticated() // Too broad: this will also catch /api/admin/**.
.anyRequest().denyAll()
);
return http.build();
}
If you have GET /api/admin/users, it also falls under /api/**. And if you did not put a more specific rule above it, the admin area becomes available to any signed-in user. Yes, “signed in” — but that is still a huge hole relative to our access matrix.
The correct logic here is simple: first the rules for privileged zones, then the more general ones. Example:
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN") // Most strict first.
.requestMatchers("/api/editor/**").hasRole("EDITOR") // Then the editor area.
.requestMatchers("/api/me/**").authenticated() // And only then the "ordinary" personal area.
.anyRequest().denyAll()
);
return http.build();
}
Notice that we did not even use /api/** as a general pattern. And that is fine. In a small educational project, it is better to have several explicit lines than one “universal” line that later accidentally turns into a “universal hole.”
6. Typical mistakes with the access matrix and formLogin
This part feels boring right up until the first time you lose half an hour to “why am I being redirected to login when I am already signed in?” The mistakes here are almost never about “complex cryptography,” but about small mismatches between intent (the access matrix) and the actual configuration (matcher order and overly broad rules). The good news: all of these mistakes are diagnosed by logic, not by magic.
Mistake #1: you “closed” /login or /logout with your own denyAll().
It looks funny: you installed a guard, but then forbade people from entering the guard booth. This usually happens when the configuration ends with anyRequest().denyAll(), while permitAll() disappeared next to formLogin or logout. If you changed loginPage, loginProcessingUrl, or logoutUrl, check that the new endpoints are open, not just the default /login and /logout.
Mistake #2: the too-broad “everything under /api/** is just authenticated()” rule.
This inevitably wipes out the boundary between the editor and admin zones. At first it even seems convenient: “well, at least it works.” Then you add an admin endpoint and suddenly realize that the ADMIN role is decorative. At a fundamental level, this is fixed only by discipline: more strict zones first, more general ones later.
Mistake #3: confusing hasRole and hasAuthority (and especially the ROLE_ prefix).
If a user is created with .roles("ADMIN"), their authority will be ROLE_ADMIN. To check a role, use hasRole("ADMIN") without the prefix. If you use hasAuthority("ADMIN"), you will be checking the string ADMIN, which the user does not have. And that is the classic “they clearly have admin, so why 403?” moment.
Mistake #4: trying to explain everything with one user.
Sometimes a student tests only user/user and concludes “everything works.” Then the editor/admin scenario comes along — and it turns out the configuration has not been checked in the important branches. At this stage, the cheap remedy is three accounts: user, editor, admin. That is not a “zoo,” it is the minimum test set for your matrix.
Mistake #5: mixing up “who signed in” and “what is allowed.”
Authentication.getName() and, in general, “the current user” are about authentication. hasRole(...) and access denials are about authorization. When you start checking roles “inside the controller with an if,” because “it is faster,” you lose the very thing you built the access map for. For today, keep one simple rule: sign-in makes the user authenticated, while access to zones is still controlled by authorizeHttpRequests.
GO TO FULL VERSION