CodeGym /Courses /Spring Security /API zone: stateless baseline

API zone: stateless baseline

Spring Security
Level 21, Lesson 4
Available

1. Scope of the transition to stateless

When you hear the word stateless, it is very tempting to make a sharp move: “that is it, we are dropping sessions, throwing away cookies, and living differently tomorrow.” But in a real project, that is usually not how it works: the system consists of zones, and each zone has its own client type and expectations. Our task, as careful engineers, is more modest and more useful: move the API zone to a stateless baseline without turning the rest of the application logic into a mess.

In our Secure Content Platform API, the “API zone” is basically everything under /api/**. It contains public reads (/api/public/**), the personal zone (/api/me/**), drafts (/api/drafts/**), editor operations (/api/editor/**), and the admin area (/api/admin/**). The main idea of the lecture is this: we do not change the access matrix itself (who is allowed where), we change only what the server relies on between requests when it tries to understand “who is this request from?”

To keep this from sounding too abstract, let us pin it down in a small table. It is not about JWT, not about tokens, only about zones and rules — the things that should remain the same.

Zone Example endpoints Access (as an idea)
Public GET /api/public/articles permitAll()
Auth entry POST /api/auth/login permitAll() (the entry point must be public)
Me GET /api/me, PATCH /api/me/profile authenticated() + owner logic inside
Drafts GET /api/drafts, PATCH /api/drafts/{id} authenticated() + owner checks
Editor POST /api/editor/drafts/{id}/publish role EDITOR (and above)
Admin PATCH /api/admin/users/{id}/lock role ADMIN

If you catch yourself thinking, “in stateless mode we need to redesign roles, permissions, and @PreAuthorize,” that is an excellent signal to stop and take a breath. Stateless is not about “changing the rules.” Stateless is about “changing the way we enter the same rules.”

If we compress the current state into one sentence: /api/** no longer relies on server-side authentication memory, /api/auth/login remains a public entry point, and we have not yet chosen how to confirm each next protected request. That is the honest state of the API zone at this step.

2. The boundary between session and stateless

The most dangerous architectural mistake here is trying to live in two worlds at once without an explicit boundary. It is like wearing a winter coat and shorts in the same weather: formally possible, but people around you will ask questions, and you will be both cold and sweaty. We need to be able to say clearly: “Here is the project state with the session-based model, here is the project state with the stateless API.”

There are two simple ways to avoid mixing the models in a project. First, separate them by code state: session-based and stateless do not argue within the same SecurityFilterChain. Second, use Spring Profiles and explicitly enable the needed configuration through spring.profiles.active. Profiles are convenient here because they isolate two different modes: the old session branch and the new stateless variant, instead of trying to keep everything in one chain at once.

I will show the profiles-based variant, because it is easy to read for a beginner.

import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@Configuration
@Profile("security-session")
class SessionSecurityConfig {
    // This configuration is enabled only in session-based mode.
    // The “old-school” security with HttpSession stays here.
}
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@Configuration
@Profile("security-stateless")
class StatelessSecurityConfig {
    // This configuration is enabled only in stateless baseline mode.
    // Here we deliberately remove the reliance on HttpSession.
}

And you can enable the profile like this at minimum (an example for local startup):

spring:
  profiles:
    active: security-stateless

Why am I so insistent about the “boundary”? Because mixing models usually breaks everything at once: somewhere an HTML login page appears in a JSON API, somewhere JSESSIONID suddenly gets created “because that is how it happened,” and somewhere you disable CSRF globally “so Postman stops complaining.” In the end, you are not learning a security model — you are learning magic and rituals. And we are against magic here.

3. Stateless settings in SecurityFilterChain

Now comes the most substantial part: changes in SecurityFilterChain. The important thing is to configure everything so that the API zone stops relying on the server’s session memory, while our URL rules, roles, and method security remain normal and readable. If you do this carefully, the project will not “fall apart”; it will simply start honestly requiring authentication on every protected request.

Let us start with a basic skeleton: we explicitly describe access by path and enable SessionCreationPolicy.STATELESS. In the example, I am intentionally not throwing in “everything at once,” so you can see where the key switch of the day is.

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

@Bean
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
    // Key switch: do not create or use HttpSession for the SecurityContext.
    http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

    // Important: STATELESS by itself does not “log in” the user — it only disables “memory of login.”
    return http.build();
}

This fragment does one thing: it tells Spring Security, “do not rely on HttpSession as the storage for the authentication result.” Authentication itself does not appear from nowhere. In other words, STATELESS does not mean “turn on login,” it means “turn off memory of login.”

Now let us add our access rules. Here we move the familiar request-level matrix: public and login are public, editor/admin are by role, everything else requires authentication.

import org.springframework.security.web.SecurityFilterChain;

// Access rules by URL: public/login are open, editor/admin are by role, everything else is for authenticated users only.
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/public/**", "/api/auth/login").permitAll() // login must be accessible without authentication
    .requestMatchers("/api/editor/**").hasRole("EDITOR")              // roles are checked at the filter level
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
    .anyRequest().authenticated()                                     // close everything that did not match above
);

Pay attention to a detail that constantly breaks projects for beginners: /api/auth/login must be explicitly permitAll(). If you close the login endpoint, the application will be very secure... simply because nobody will be able to log in. It is like putting a safe door in front of the safe entrance and throwing away the key.

Now let us assemble this into a more coherent piece. I am showing the “almost final” form for the stateless baseline. You can adapt the profile and package to your project structure.

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

@Configuration
@Profile("security-stateless")
class StatelessSecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // First, we describe the access matrix by URL.
        http.authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**", "/api/auth/login").permitAll() // entry point must be public
                .requestMatchers("/api/editor/**").hasRole("EDITOR")
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
        );

        // Then we explicitly lock in stateless behavior (without storing the SecurityContext in the session).
        http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }
}

At this step, we have formally moved the API zone into stateless mode. But there is one more thing you cannot “roll back”: our API is already configured to return JSON errors 401/403. That must continue to work here, otherwise you will suddenly see redirects or HTML pages, and Postman will stare at you in silent judgment.

If you already have an AuthenticationEntryPoint and AccessDeniedHandler from the REST-friendly errors block, we simply wire them into exceptionHandling.

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

// Make the errors API-compatible: no HTML/redirects, expected 401/403 in JSON format.
http.exceptionHandling(ex -> ex
    .authenticationEntryPoint(restAuthenticationEntryPoint) // 401: when the user is not authenticated
    .accessDeniedHandler(restAccessDeniedHandler)           // 403: when the user lacks permissions
);

Here it is important to keep the focus on the model change itself. The server stops relying on the session and honestly requires every protected request to be authenticated again. We are not mixing in the concrete portable proof of access at this step: first we need to see the effect of removing server-side memory.

Still, if you want to manually test protected endpoints before such proof exists, you can temporarily enable HTTP Basic as a diagnostic bridge. This is not the model the API zone is built on right now; it is just a way to verify that STATELESS, roles, and JSON errors behave predictably.

import static org.springframework.security.config.Customizer.withDefaults();

// Temporary diagnostic bridge for manually checking protected endpoints.
http.httpBasic(withDefaults());

If you decide to add this bridge, be sure to state the meaning clearly to yourself: this is not “we changed our minds and chose Basic forever,” this is “we want to verify right now that stateless mode works, and that role-based authorization and rules are not broken.”

4. API behavior in stateless mode

The transition to stateless almost always makes a beginner feel like the application is “broken.” And the most insidious part is that it really does stop behaving the way it did in the session world. But that is not a bug; it is the effect we wanted: the server stops remembering the past, which means the happy “I logged in once and everything works” is no longer applicable.

Let us look at this on the simplest endpoint — /api/me. We had it both in the session branch and in the overall project design. Important: the endpoint itself does not change, the access context does.

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class MeController {

    @GetMapping("/api/me")
    String me(@AuthenticationPrincipal UserDetails user) {
        // In the stateless model, this user appears only if the request carried valid authentication.
        // In other words, “memory of login” does not work here — you need proof on every request.
        return user.getUsername(); // for example: "alice"
    }
}

In the session model, the scenario looked like this: you signed in through form login, the server created or reused HttpSession, put SecurityContext there, and subsequent requests with JSESSIONID were automatically treated as “from the same user.”

In the stateless baseline, the picture is different: if you made a POST /api/auth/login, the server can of course verify the credentials through AuthenticationManager and return a JSON response saying “yes, you were authenticated.” But on the next GET /api/me, if the client did not provide any proof of authentication, Spring Security will honestly say, “Sorry, I do not know you” — and return 401.

Here is the minimal sequence that is useful to keep in mind (without JWT, without details, only the logic):

sequenceDiagram
    participant C as Client
    participant S as Spring Security
    C->>S: "POST /api/auth/login (username+password)"
    S-->>C: "200 OK (login response)"
    C->>S: "GET /api/me (without proof)"
    S-->>C: "401 Unauthorized (no authentication)"

So by this point the picture is intentionally incomplete, but already honest: we have login as an event, we no longer have server-side memory, and the next protected request still needs to bring a separate proof of access. That is exactly where the main engineering question of the stateless model comes from: what will the client attach to each protected request instead of session memory.

If you have HTTP Basic enabled as a temporary bridge, you can see that /api/me starts working again — but in a stateless way: credentials are sent with every request, and the server must not remember you through the session.

An example request that shows the difference well:

curl -i -u alice:password http://localhost:8080/api/me

And here it is especially nice to observe that @AuthenticationPrincipal did not “break.” It works exactly the same: if there is an authenticated user on the request, you get that user. Only now, in the stateless world, this user must appear on every request again from the request data itself.

And the editor/admin zones at this step should behave very predictably: if you are not authenticated, you get 401; if you are authenticated but not an EDITOR, you get 403. That is exactly the discipline we built through AuthenticationEntryPoint and AccessDeniedHandler.

5. Method security and owner checks

After switching to stateless, many people get the urge to “redo everything from scratch.” That is a very human feeling: if the basic login behavior has changed, it seems like service-layer protection should change too. But this is exactly where a good architecture shines: method security, owner-based rules, and access to the current user stay where they are. Only how Authentication appears in the current request context changes.

Let me restate the main principle: URL rules are the first line of defense, and method security is protection of business actions. Stateless does not cancel either line. The fact that publish is available only to EDITOR is still expressed where the publish operation happens — in the service.

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
class DraftService {

    @PreAuthorize("hasRole('EDITOR')")
    public void publish(Long draftId) {
        // We protect the business action at the service level, not “move” the checks into the controller.
        // Stateless changes how Authentication appears, but not where business rules live.
    }
}

The same applies to owner checks. If your service must guarantee that “a user can update only their own draft,” that rule should not migrate into the controller just because you changed the auth state storage model. Yes, in the stateless model you need a correct current user on every request, otherwise ownership cannot be checked. But that does not mean ownership logic should become a “web layer” concern.

An important nuance that is worth stating separately: SecurityContextHolder and @AuthenticationPrincipal do not become “forbidden.” They remain normal tools. You just need to get used to the idea that if the request arrives without authentication, then the controller should not execute at all (the filter chain stops it earlier), or you get Authentication as anonymous/empty (depending on the configuration). So the problem is not that “Spring broke your current user,” it is that “in stateless mode, you can no longer rely on memory of yesterday’s login.”

6. Typical mistakes when moving a project to a stateless baseline

At the end of this lecture, it is useful to record the land mines people step on most often. And the good news is that most of them are not about “complicated security,” but about simple inattentiveness and the attempt to mix two models at once.

Mistake #1: close /api/auth/login together with all protected endpoints.
This is a classic: you write anyRequest().authenticated(), forget permitAll() for the login endpoint above, and end up with a system where login is only possible after login. It looks like a perpetual motion machine, but in security terms it is just a dead end. The fix is one move: the login endpoint is always declared public and visible in the configuration.

Mistake #2: expect stateless = “log in once and the server keeps remembering.”
In the stateless baseline, the server is not supposed to retain authentication state between requests. If you verified the password on POST /api/auth/login, that does not mean the next GET /api/me will become authenticated “by inertia.” If you see 401 on the next request, that is not a reason to turn security off; it is a reason to remember that we asked for STATELESS ourselves.

Mistake #3: try to keep browser semantics in the API zone.
Sometimes in a stateless config people forget to disable, or do not touch, things that lead to HTML pages, redirects, or other “browser-style” reactions. The result is that Postman gets unexpected HTML. If you already have REST-friendly AuthenticationEntryPoint and AccessDeniedHandler, wire them in and do not let the API drift back into the past.

Mistake #4: mix the session branch and the stateless branch in one SecurityFilterChain without a clear boundary.
Technically, you can write one huge configuration “for all cases,” but methodologically it is almost always a failure: it is unclear why something works sometimes and not others; a session got created somewhere; it disappeared elsewhere; CSRF suddenly shoots you in the foot somewhere else. For a course (and for most projects at the beginning), it is better to have a clear boundary: profile, branch, tag — anything, as long as it is clear which world is currently active.

Mistake #5: drag owner-based checks back into controllers “because now there is no other way.”
When session memory disappears, there is a temptation to “check everything right in the controller,” because “that is where the request is.” That is an architectural step backward. Stateless changes only the way Authentication appears on the request; it does not cancel the fact that business rules should live near the business operation. If the service used to be protected with @PreAuthorize, it should remain that way.

1
Task
Spring Security, level 21, lesson 4
Locked
Stateless access matrix for the API zone
Stateless access matrix for the API zone
1
Task
Spring Security, level 21, lesson 4
Locked
Method security stays in the service after switching to stateless
Method security stays in the service after switching to stateless
1
Survey/quiz
Spring Security, level 21, lesson 4
Unavailable
Spring Security
Sessions and authorization
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION