CodeGym /Courses /Spring Security /Final request-level API config

Final request-level API config

Spring Security
Level 18, Lesson 4
Available

1. Final request-level configuration

When you add security rules in small chunks “as the pain appears,” the config quickly turns into a graveyard of strings: one matcher here, another one there, somewhere a permitAll() for testing appears out of nowhere, and at the bottom — a spell called anyRequest()authenticated() “just in case.” Final configuration is the moment when we stop patching symptoms and assemble an access map as a document that can be read like a legend: what is public, what requires login, what requires a specific permission, and what is closed forever.

We already have all the pieces separately: coarse-grained zones, precise matchers by method + path, rules for docs and actuator, plus the understanding that one chain is enough for this task. Now we combine all of that into one working variant: a single-chain config that we keep as the main request-level map of the application.

Important idea: request-level configuration is the first line of defense, a “security check at the door.” It should not try to solve everything in the world (for example, “is this my draft or someone else’s?”), but it must clearly separate zones and leave no “gray” routes. In human terms, request-level rules are the guard at the building entrance, not the court process inside the office. The guard does not figure out who owns a particular chair in the meeting room, but does know who can enter the building at all and which floor they may go to.

Constants for paths and permissions

As soon as you start writing "/api/public/articles/**" in three places, the universe hints: “Time to move this into constants.” First, you make fewer mistakes. Second, the configuration starts reading as semantic blocks instead of a list of spells. Third, if the path changes tomorrow, you will not have to play a mini-quest called “find and replace 7 identical strings, but leave one — it’s in a test.”

Let’s make a small teaching compromise: declare groups of paths right inside SecurityConfig as static final arrays. This is not “perfect architecture for the ages,” but for a course and a small project it is ideal: fast, clear, and without extra classes.

package com.example.securecontent.security.config;

import org.springframework.http.HttpMethod;

public final class SecurityPaths {

    // Public storefront: published articles are available without login (but read-only).
    public static final String[] PUBLIC_ARTICLES = {
            "/api/public/articles",   // collection
            "/api/public/articles/**" // specific articles and subroutes
    };

    // Documentation is also made of HTTP endpoints, and it is convenient to keep them as a separate “zone.”
    public static final String[] DOCS = { "/swagger-ui/**", "/v3/api-docs/**" };
}

Notice: there is no magic here. We simply gave names to two zones. In a real project you would also define ME, DRAFTS, maybe ADMIN_USERS, and the config would start to look like a subway map: lines are named, stations are grouped, and you are not trying to reconstruct from memory “where is the transfer to the blue line?”

Skeleton SecurityFilterChain

Before writing rules, it helps to see the chain “skeleton.” At the fundamentals level it should be short: we want to show clearly that there is an authorizeHttpRequests block, there is error handler wiring, and there is build(). Everything else (CSRF, CORS, login mechanics) we leave alone today: not because it is unimportant, but because right now we are assembling precisely the request-level access map.

package com.example.securecontent.security.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;

@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> {
            // All request-level rules live here (routes/methods/roles).
            // The idea: reads like an “access map,” not like a pile of random strings.
        });

        // At the end, we always build the filter chain.
        return http.build();
    }
}

For now this is an “empty map.” But the structure matters: all request-level rules live in one place, which means you get a chance (rare, appreciate it) to keep the security config readable and short instead of turning it into a 12-season series.

2. Rules in blocks: public → user → privileged

When the number of rules grows, the best way not to make mistakes is to write them in reading order, not in the order “as controllers are laid out in packages.” Usually that order is: first public exceptions, then areas for logged-in users, then privileged areas (editor/admin), then docs and service routes, and at the very end — an explicit lock on everything else. We do not want an unfamiliar route to become open simply because you forgot about it in the config.

Below we will write small fragments “inside authorizeHttpRequests”. In real code you would just place them one after another — exactly in this order.

Public read: published articles are read-only

The public area is not “just a convenience for a demo,” but part of the domain: a user really should be able to read published content without registration. But it is important to emphasize “read” — specifically GET. As soon as we make POST or PATCH public, we start writing a course called “How to accidentally turn a site into an anonymous bulletin board.” We do not need that.

// inside authorizeHttpRequests(auth -> auth ...)

// Allow only reading public articles without authentication.
auth.requestMatchers(HttpMethod.GET, SecurityPaths.PUBLIC_ARTICLES)
        .permitAll();

This fragment does two things. It fixes the “public read” rule formally and does not let it accidentally grow. And it sets the style for the whole config: method + zone + clear policy.

Authenticated zone: personal area and drafts

The personal area (/api/me/**) and drafts (/api/drafts/**) are a classic example of “do not even try without authentication.” At the request level we are not solving ownership yet (“is this my draft?”), but we must do the minimum: anonymous users do not enter. This reduces noise, simplifies later checks, and makes API behavior more predictable.

// inside authorizeHttpRequests(auth -> auth ...)

// User’s personal area: no login, no entry.
auth.requestMatchers("/api/me", "/api/me/**")
        .authenticated();

// Drafts: also only for authenticated users.
auth.requestMatchers("/api/drafts", "/api/drafts/**")
        .authenticated();

Yes, these rules are simple. And that is normal. Simplicity is not “poverty,” but discipline: we deliberately leave more subtle restrictions for the next layer of protection, otherwise the request-level config will turn into a mini-language for describing all business logic.

Editor zone: moderation and publishing

The editor zone is the place where beginners often make mistakes: they set just authenticated() and think, “well, it is still not public.” But in our domain, editor actions are a privilege. We express it through authorities such as draft:review and draft:publish. Important: here we also show method-specific rules: reviewing the queue is GET, publishing is POST.

// inside authorizeHttpRequests(auth -> auth ...)

// Moderation queue: available only to users with the review right.
auth.requestMatchers(HttpMethod.GET, "/api/editor/review-queue")
        .hasAuthority("draft:review");

// Publishing a draft: separate publish right.
auth.requestMatchers(HttpMethod.POST, "/api/editor/drafts/*/publish")
        .hasAuthority("draft:publish"); // * — exactly one path segment (for example, id)

Note the * in the path: it is one segment (for example, a draft id). If you write /**, you will broaden the rule too much — and sometimes that ends with someone unexpectedly getting access to unexpected subroutes.

Admin zone: reading and management use different permissions

The admin area is very tricky. First, because you want to “ship it faster, we will lock it down later.” Second, because inside it there are almost always different levels of operations: reading the user list is usually easier than changing roles or blocking an account. We will encode this idea directly in the config: GET requires user:read, and PATCH requires user:manage.

// inside authorizeHttpRequests(auth -> auth ...)

// Viewing users — one permission.
auth.requestMatchers(HttpMethod.GET, "/api/admin/users", "/api/admin/users/**")
        .hasAuthority("user:read");

// Changing users — another (usually stronger) permission.
auth.requestMatchers(HttpMethod.PATCH, "/api/admin/users/**")
        .hasAuthority("user:manage");

This looks “a bit longer” than one matcher on /api/admin/**, but it makes the contract very clear: reading and changing are different stories. And if tomorrow DELETE /api/admin/users/{id} appears, you will immediately see where to place it logically.

Docs: Swagger as an ordinary endpoint

Swagger UI and OpenAPI JSON are ordinary HTTP routes. Sometimes people forget to protect them because “it is just documentation.” But documentation often contains a list of all endpoint’s, their structure, and sometimes examples. That is not necessarily super-secret, but keeping it under control is a healthy habit. In a learning project, the simple and clear option is: accessible to authenticated users.

// inside authorizeHttpRequests(auth -> auth ...)

// Show documentation only to logged-in users (so it is not exposed by default).
auth.requestMatchers(SecurityPaths.DOCS)
        .authenticated();

We could make docs available only to admins or only in the dev profile, but then this starts to smell like a separate environment policy. Today we keep the config simple and practical.

Actuator: allow the minimum, close the rest

Service endpoints are where allow-list thinking is especially useful. We open only what is really needed: health can be public so monitoring does not require login, info only for logged-in users, and everything else is closed via denyAll(). This matters: otherwise at some point you enable an additional actuator endpoint and it “just happens” to be accessible.

// inside authorizeHttpRequests(auth -> auth ...)

// Health is usually needed by monitoring — make it public.
auth.requestMatchers("/actuator/health").permitAll();

// Info may contain useful details — keep it only for logged-in users.
auth.requestMatchers("/actuator/info").authenticated();

// Everything else in actuator is closed outright (allow-list approach).
auth.requestMatchers("/actuator/**").denyAll();

Yes, denyAll() is useful. It is like a sign that says “no entry” on the server-room door: not because we do not trust specific people, but because this place should not be open by accident.

Final rule: close the remainder

The most common problem in real projects is not that a rule is written poorly, but that the rule is not written at all. A new endpoint appears, you forget to add a matcher, and it ends up in the “gray zone.” That is why the final line of the request-level configuration must be brutally honest: if the route did not match any zone, it is closed.

// inside authorizeHttpRequests(auth -> auth ...)

// Everything not described above is forbidden: “no rule means no access.”
auth.anyRequest().denyAll();

This is not “paranoia.” It is discipline. And it helps a lot during refactoring: you immediately see that a new endpoint does not work, and you go add it to the access map instead of accidentally leaving it open.

3. Assembling the config and REST-friendly 401/403

REST-friendly 401/403

After day 17 we already have normal REST semantics for errors: 401 means “you are not authenticated,” and 403 means “you are logged in, but you are not allowed.” Here it is important not to break this behavior, otherwise API clients will start getting HTML pages, redirects, or strange “lucky” responses again.

Therefore, in the final config we explicitly wire an entry point and a denied handler. From the perspective of reading the configuration, this looks nice: access rules are in one block, error policy is in another.

import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;

// after authorizeHttpRequests(...)

// 401: this is where a request with no authentication ends up (for example, no token).
// 403: this is where a request with a logged-in user but insufficient privileges ends up.
http.exceptionHandling(ex -> ex
        .authenticationEntryPoint(entryPoint)
        .accessDeniedHandler(deniedHandler)
);

And here a neat engineering effect appears: you can rearrange the matchers ten times, but as long as exceptionHandling is wired up, the client will keep receiving predictable JSON with the correct statuses.

Compact final version of SecurityConfig

Now let’s show the complete final version. It may not be 100% identical to your current code (everyone has different checkpoints and branches), but the idea should match: rules are grouped, the order is clear, the remainder is closed, and the error handlers are wired.

Below is the config you can already rely on as a complete request-level map of the application: public, user, privileged, docs, actuator, and predictable 401/403 are collected in one place.

package com.example.securecontent.security.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;

@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http,
                                            AuthenticationEntryPoint entryPoint,
                                            AccessDeniedHandler deniedHandler) throws Exception {

        http.authorizeHttpRequests(auth -> auth
                // 1) Public: reading published articles without login (GET only).
                .requestMatchers(HttpMethod.GET, SecurityPaths.PUBLIC_ARTICLES).permitAll()

                // 2) User: personal and working areas — only for authenticated users.
                .requestMatchers("/api/me", "/api/me/**").authenticated()
                .requestMatchers("/api/drafts", "/api/drafts/**").authenticated()

                // 3) Editor: separate rights for review and publish.
                .requestMatchers(HttpMethod.GET, "/api/editor/review-queue").hasAuthority("draft:review")
                .requestMatchers(HttpMethod.POST, "/api/editor/drafts/*/publish").hasAuthority("draft:publish")

                // 4) Admin: reading and management are separated by permissions and methods.
                .requestMatchers(HttpMethod.GET, "/api/admin/users", "/api/admin/users/**").hasAuthority("user:read")
                .requestMatchers(HttpMethod.PATCH, "/api/admin/users/**").hasAuthority("user:manage")

                // 5) Docs: documentation as an ordinary area.
                .requestMatchers(SecurityPaths.DOCS).authenticated()

                // 6) Actuator: allow-list, close everything else.
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers("/actuator/info").authenticated()
                .requestMatchers("/actuator/**").denyAll()

                // 7) Lock on everything else: if it is not described, it is forbidden.
                .anyRequest().denyAll()
        );

        http.exceptionHandling(ex -> ex
                // REST error semantics: 401 and 403 should be predictable for the client.
                .authenticationEntryPoint(entryPoint)
                .accessDeniedHandler(deniedHandler)
        );

        return http.build();
    }
}

If you look at this config as text, it reads almost like documentation: first public reading, then personal area, then moderation, then admin, then docs, then actuator, then “close everything else.” That is exactly the goal of a “final request-level configuration”: not “write more DSL,” but assemble an access map without logical holes. And it is important not to fool yourself here: such a config still does not prove whose draft it is or whether a specific business operation may be performed on it. The request-level layer honestly stops at the entrance.

4. Typical mistakes in a request-level config

Mistake #1: forget the closing rule and accidentally leave “gray” routes.
When the config has no explicit anyRequest()denyAll() (or at least no explicit default decision), a new handler can appear and “somehow work,” even though you have not yet included it in the access matrix. That is unpleasant precisely because it seems convenient: “oh, it opened by itself.” In security this usually means not “convenient,” but “we did not notice a hole.”

Mistake #2: mix up the order and place a broad rule above a narrow one.
Inside authorizeHttpRequests rules are checked from top to bottom. If you first write /api/** authenticated(), and below try to make GET /api/public/articles/** permitAll(), the public rule may already not work. The most reliable approach is to start with exceptions and narrow rules, and then expand toward general zones.

Mistake #3: accidentally make a privileged zone “just authenticated.”
Sometimes /api/editor/** and /api/admin/** get, by habit, dropped under the general block “everything that is not public — authenticated.” Formally the application is “protected,” but in practice editor/admin operations become available to an ordinary user. This is one of those mistakes that are especially unpleasant: you do not see it with your eyes until you check with another account.

Mistake #4: open /actuator/** completely because “it is just health.”
Public /actuator/health is normal practice, but public /actuator/** is usually too broad. Actuator can contain a lot of interesting things: metrics, env, mappings, and other things you definitely do not want to show to everyone. If you are not sure what is enabled, it is better to stick to the allow-list: open specific endpoints, and close the rest with denyAll().

Mistake #5: lose REST-friendly 401/403 because you did not wire the handlers in the final config.
You can assemble the matchers perfectly, but if you forget exceptionHandling(...) with your AuthenticationEntryPoint and AccessDeniedHandler, the client will start receiving the wrong error format again. In the end Postman will show strange redirects or HTML, and you will think the access rules broke. In reality, the “error semantics” broke.

1
Task
Spring Security, level 18, lesson 4
Locked
Final request-level access map for Review Hub API
Final request-level access map for Review Hub API
1
Task
Spring Security, level 18, lesson 4
Locked
Final access map with JSON errors for Warehouse API
Final access map with JSON errors for Warehouse API
1
Survey/quiz
Access control, level 18, lesson 4
Unavailable
Access control
Request access rules
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION