CodeGym /Courses /Spring Security /Rule order and access matrix

Rule order and access matrix

Spring Security
Level 5, Lesson 4
Available

1. Rule order is behavior

When you first look at authorizeHttpRequests, it is very tempting to think: “Well, it is just a list of rules... what difference does the order make? They are all ‘simultaneous’ anyway.” Alas, Spring Security is not a telepath. It does not read your intentions — it reads the configuration from top to bottom. And if there is a rule at the top that matches almost everything, the lower rules will simply never be reached. This is not a bug and not a framework whim, but a normal security model: earlier and higher-priority rules are checked first.

By now, we already have all the building blocks: we know how to split an API into zones with requestMatchers, how to distinguish authenticated() from stricter checks, and how to write separate rules for roles or full denial. Now the important part is to assemble all of this into one map without accidental holes.

The problem becomes very practical as soon as you have a public zone inside the overall API space. For example, /api/public/** is the open part, while /api/** is generally private. If you accidentally place the “general barrier” first, you will close the public area, and nobody will be able to read articles without logging in. And vice versa: if you place the “allow anyone logged in” rule before “admin only,” then the admin area will become available to any authenticated user. It is like putting a “staff entrance” sign on the supermarket door... and then wondering why your storage room is crowded.

To be completely fair: order mistakes are the kind of mistakes that are especially unpleasant for beginners. The code looks “logical,” it compiles, the application starts, but the actual behavior does not match what you expected. So today we are not just going to “write a matrix” — we are going to learn how to read it through Spring Security’s eyes.

2. “First match wins”

One of the best ways to stop being afraid of Spring Security is to imagine that authorizeHttpRequests creates a list of rules, and each rule is a pair: “matcher” + “decision.” At runtime, a request arrives, and Spring Security tries the rules one by one. The first rule whose matcher matches will be applied. The remaining rules below are not considered anymore, even if they are “more correct” in your view. This is very similar to how firewalls, routers, and even a club bouncer work, when there is one sheet of paper with entry conditions (and he is not required to read it to the end if he has already found a matching line).

If we express this idea in pseudocode (without diving into Spring Security’s internal classes), it looks roughly like this:

// Simplified model: rules are checked strictly top to bottom
for (Rule rule : rules) {
    // If the matcher matches, we do not look any further — this is "first match wins"
    if (rule.matches(request)) {
        // The decision can be permitAll / authenticated / hasRole / denyAll ...
        return rule.decision();
    }
}

// If nothing matches, the default policy kicks in (depends on the configuration)
return defaultDecision;

Let us visualize this even more simply — as a mini flowchart:

flowchart TD
    A["HTTP request arrives at the application"] --> B{"Does Rule #1 matcher match?"}
    B -- yes --> R1["Apply decision #1 and stop"]
    B -- no --> C{"Does Rule #2 matcher match?"}
    C -- yes --> R2["Apply decision #2 and stop"]
    C -- no --> D{"Does Rule #3 matcher match?"}
    D -- yes --> R3["Apply decision #3 and stop"]
    D -- no --> E["Reach anyRequest / default"]

Now the key takeaway sounds almost like a meme, but it is completely engineering-based: it is not the “strictest” rule that wins, but the first matching one. That is why order is not cosmetics, but part of the logic. And since we are the ones writing SecurityFilterChain, we are also responsible for making sure this order reflects our access matrix.

3. Matcher mistakes and an open admin area

The most common order mistake looks harmless: you place the “general barrier” before the “exception.” For example, you want: “everything in the API is closed except public.” And you write this:

import org.springframework.security.config.annotation.web.builders.HttpSecurity;

// Mistake: a too broad matcher is placed above the exception
// As a result, /api/public/** also falls under /api/** and requires login
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/**").authenticated()
    .requestMatchers("/api/public/**").permitAll()
);

In human language, it is as if you said: “everything in the API requires login, but public is an exception.” And in the language of check order, you said: “if the URL starts with /api/, require authentication.” That is all. Because /api/public/** also starts with /api/, so the first rule matched, and we did not even get to permitAll().

The correct order for an “exception” is: exception first, then the general barrier:

import org.springframework.security.config.annotation.web.builders.HttpSecurity;

// Correct: first the narrower exception rule, then the broader rule
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/public/**").permitAll() // public should run first
    .requestMatchers("/api/**").authenticated()    // everything else under /api/ — only for logged-in users
);

This mistake is annoying, but there is an even trickier one. Suppose you have already made “all API for logged-in users,” and then decide: “admin should be only for admins.” And you add the line below:

import org.springframework.security.config.annotation.web.builders.HttpSecurity;

// Mistake: the rule "admin only for ADMIN" will not work,
// because /api/** will intercept /api/admin/** earlier
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/**").authenticated()
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
);

It looks perfectly reasonable... until you remember that /api/admin/** also falls under /api/**. That means the first rule fired earlier. And the real effect is this: the admin area becomes available to any authenticated user. It is as if you put a sign on the server room door that says “entry by pass only,” and on the door labeled “director” you added “entry only for the director,” but forgot that the guard reads signs from top to bottom and stops after the first check.

The correct order here is: first the “highly protected zones,” then the general barrier:

import org.springframework.security.config.annotation.web.builders.HttpSecurity;

// Correct: first the "VIP zone", then the general barrier
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/admin/**").hasRole("ADMIN") // the strictest and narrowest goes first
    .requestMatchers("/api/**").authenticated()        // general access for logged-in users goes below
);

Here you are clearly saying: “admin is a separate VIP entrance,” and only then — “you may enter the rest of the API if you are simply logged in.”

And one more nuance that saves you from very strange holes in real projects: close the tail. If you do not specify what should happen to the remaining requests, you can easily get “default” behavior that you definitely do not want to leave implicit today. That is why we like the final anyRequest() line — like the last net on the gate.

4. The first access matrix in code

Now we are going to do what this was all about: take the access zones of the Secure Content Platform API and put them into one readable block. Important: this matrix is not about “real users from the database” and not about “fine-grained permissions at the level of individual operations” yet. This is the first practical layer: separate the basic zones by URL so that the application becomes predictably protected.

Let us first write the matrix as a table. This is useful not only for students — it is generally a good engineering habit: first design access as an agreement, and then move it into DSL.

Zone URL examples Decision
Public /api/public, /api/public/** permitAll()
Me (personal area) /api/me, /api/me/** authenticated()
Editor /api/editor/** hasRole("EDITOR")
Admin /api/admin/** hasRole("ADMIN")
Everything else anyRequest() denyAll()

For the public and me zones, we explicitly list both the root endpoint and nested paths. This makes the policy readable without guesswork: if a zone has a separate “entry point” and an URL subtree, it is visible directly in the config.

The chain skeleton stays the same: the basic sign-in methods remain a convenient learning aid for manual verification, while the access policy itself changes.

Now let us move this into SecurityFilterChain. I will show the final version — the actual “matrix in code” — and then explain why the order is exactly like this and why denyAll() is useful at the bottom.

package com.example.securecontent.security.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
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 {
        return http
            // The matrix is read top to bottom: first match stops the check
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public", "/api/public/**").permitAll()   // 1) public zone is open to everyone
                .requestMatchers("/api/me", "/api/me/**").authenticated()       // 2) personal area — only for logged-in users
                .requestMatchers("/api/editor/**").hasRole("EDITOR")            // 3) editor zone — EDITOR role required
                .requestMatchers("/api/admin/**").hasRole("ADMIN")              // 4) admin zone — ADMIN role required
                .anyRequest().denyAll()                                         // 5) close the tail explicitly so there is no "whatever happens"
            )
            // Keep basic sign-in methods for manual rule checking
            .formLogin(Customizer.withDefaults())
            .httpBasic(Customizer.withDefaults())
            .build();
    }
}

Why is this order safe and readable?

First, we explicitly placed the public zone first. Psychologically, this is convenient: you immediately see “what is open to the world.” And it is technically convenient: a narrower zone is placed above potentially broader rules (for example, if later you add a general /api/**). Second, we progressively “narrow access” as we move down: from public to authenticated, then to privileged zones. Here it is important not to get confused: privileged zones (/api/editor/**, /api/admin/**) do not have to be below /api/me/** from the matcher’s point of view, because they are different subtrees. But in this order they are easier to read: the configuration becomes like a map of the application.

Third, and very important: anyRequest().denyAll() makes behavior predictable. If you add a new controller and forget to write a rule for it, it will not become “accidentally available.” It will become explicitly unavailable until you make a decision and write it into the matrix. This is a direct continuation of the secure-by-default idea: better “closed until consciously opened” than “open until you happen to remember to close it.”

If you want even more discipline in readability, you can move path patterns into constants. This is not mandatory, but many teams do it to avoid “magic strings”:

package com.example.securecontent.security.config;

// Path constants: fewer "magic strings" and less risk of typos
final class ApiPaths {
    static final String PUBLIC = "/api/public/**";
    static final String ME = "/api/me/**";
    static final String EDITOR = "/api/editor/**";
    static final String ADMIN = "/api/admin/**";

    // Prevent instantiation: this is a utility class
    private ApiPaths() {
    }
}

Notice: this is not “about beauty for beauty’s sake.” It is about the fact that in a week you will be looking for where access to /api/editor/** is defined, and you want to see it as one single term, not as a string written in four places with three different typos.

5. Manual rule checking

Security configuration is a tricky thing: the brain likes to think “well, I wrote it correctly, so it must work correctly too.” But a useful habit is to quickly check 2–3 control scenarios after each change to the filter chain. This does not replace tests (we will get to those later, but not today), yet it saves a lot of time on the question “why am I suddenly getting 403 everywhere.” Here it is especially useful to look at two modes: an anonymous request and a request from a logged-in user.

At this stage, we already have the access rules layer assembled. The config says which zones are public, which require just a login, and which require a role. But the roles and credentials themselves still exist in a very educational form. In other words, the access map is already understandable, while the user side is still asking for the same level of order.

If your project has at least minimal endpoints, for example /api/public/ping and /api/me, then verification looks like this. For the public endpoint, you expect it to be accessible without login:

curl -i http://localhost:8080/api/public/ping

For the personal area, you expect that access will not be granted without authentication:

curl -i http://localhost:8080/api/me

Then you make the same request, but as an authenticated user. In our context, this might be the starter user from Spring Boot that you have already seen before. And you expect /api/me to become available, while /api/admin/** should not, because the starter user usually does not have the ADMIN role. At this stage, the important thing is not to “catch the perfect status code,” but to see the main point: the matrix really works, and the privileged zone does not open by accident.

And the especially useful check for this lecture is to deliberately break the order, make sure that exactly what you expected breaks, and then restore the order. It is like simulator training: better to “crash a toy admin area” once on purpose than to accidentally open it in a real project and then heroically close it in production at 3 a.m.

6. Typical mistakes in rule order

Mistake #1: a general matcher is above a specific one, and “exceptions” stop working.
This mistake looks harmless because the configuration contains both rules. But Spring Security does not “merge them into one” — it goes top to bottom. So a line like .requestMatchers("/api/**").authenticated() above .requestMatchers("/api/public/**").permitAll() makes the public zone not public. The fix is simple: narrow exceptions first, broad rules later.

Mistake #2: a barrier on /api/** accidentally opens privileged zones.
Sometimes a developer honestly writes “all API only for logged-in users,” then adds “admin only for ADMIN” below and thinks everything is fine. In reality, /api/** intercepts /api/admin/** first, and the admin area becomes accessible to any logged-in user. This is especially dangerous because it may not show up for a long time in manual testing with a single user. The right approach is to put admin/editor rules above the general /api/**, or even explicitly list zones without a general barrier at an early stage.

Mistake #3: forgetting to close the tail and leaving behavior as “whatever happens.”
If you do not end the configuration with something like .anyRequest().denyAll() (or at least .anyRequest().authenticated()), you hand part of the behavior over to defaults, auto-configuration, and random neighboring settings. In a learning project, this is especially harmful: you want to see the access map with your eyes, not guess it from symptoms. A good configuration ends with an explicit rule for “everything else.”

Mistake #4: the configuration turns into a “long scroll” without grouping, and it becomes impossible to read.
Technically, you can throw twenty requestMatchers in a row in random order — Spring Security will swallow that. But two weeks later you yourself will no longer remember why /api/me/profile ended up above /api/me, and why /api/admin/users is somehow in the middle of the public block. The fix is discipline: group zones, keep order, add blank lines, or move paths into constants. Security configuration should read like a document; otherwise it turns into a minefield.

Mistake #5: trying to “test everything” without understanding that the current user may not have the needed roles.
At this point in the course, you often have only a basic user (for example, the default one), and that user may not have the EDITOR or ADMIN roles. That is normal: you can still verify that the zones are indeed closed. If you see that editor/admin endpoints are unavailable, that does not mean “everything is broken” — it is often exactly what should happen on the first access matrix.

1
Task
Spring Security, level 5, lesson 4
Locked
First access matrix for five endpoints
First access matrix for five endpoints
1
Task
Spring Security, level 5, lesson 4
Locked
Correct order for the admin rule and the general `/api/**`
Correct order for the admin rule and the general `/api/**`
1
Survey/quiz
Request protection, level 5, lesson 4
Unavailable
Request protection
Configuring access rules
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION