CodeGym /Courses /Spring Security /Token errors and API request flow

Token errors and API request flow

Spring Security
Level 24, Lesson 4
Available

1. The role of token error handling

To be honest, people are not afraid of token errors because they are complicated, but because they are embarrassing: you confidently say, “we have JWT, we are modern,” and the client gets 500 and a strange stack trace. At that moment, “modernity” quickly turns into “let’s just disable the filter so it works.” To avoid that, you need to decide in advance: what kinds of token failures exist, who catches them, what status is returned, and how this fits into the API’s single JSON error contract.

An important idea: when a token is broken or expired, this is not a business error and not a “controller error.” It is a failure at the security boundary. That means the error must appear before the controller, in the filter chain, and it must be formatted so that the client can clearly understand: “I am not authenticated” means 401, not “I am forbidden” means 403.

Four request states: no token, bad token, good token, not enough rights

To avoid confusion, it helps to keep in mind not ten different “JWT pain variants,” but just four request states. They are enough to explain almost any system behavior and to keep debugging from turning into a shamanic ritual with a drum and DEBUG logs. These states also map nicely to the familiar boundary: authentication answers “who are you?”, and authorization answers “what are you allowed to do?”.

Below is a table that will be your mini-navigator through the entire stateless branch of the project:

Request state What we see on input What it means What it should end with Where the response is “born”
missing token The Authorization header is missing, or it is not Bearer ... The client did not try to authenticate in this request For a public endpoint — 200. For a protected endpoint — 401 Often “by itself” Spring Security (via AuthenticationEntryPoint), and the filter just passes the request through
malformed / invalid token The Bearer ... header is present, but the token cannot be parsed / the signature does not match The client tried, but the credentials are invalid 401 in the JSON contract Our JWT filter, because going further makes no sense
expired token The token is correct in format/signature, but exp is already in the past The client tried, but the token lifetime has ended 401 in the JSON contract Our JWT filter, if we decided to reject an invalid Bearer right at the security boundary
valid token, but forbidden The token is valid, Authentication has been restored, but there are not enough rights The user exists, but they are not allowed 403 in the JSON contract This is already the authorization zone: request rules / method security + AccessDeniedHandler

To make this easier to picture, you can think of it as a mini flowchart. It is as simple as an if-else, but that simplicity is exactly what saves your nerves:

flowchart TD
    R[HTTP request] --> H{"Authorization: Bearer ... ?"}
    H -->|no| A[Stay anonymous
continue down the chain] H -->|yes| V{"Token valid?"} V -->|no / expired| U[Return 401 JSON
and finish the request] V -->|yes| C[Build Authentication
put it in SecurityContext] C --> Z["Continue: request rules + @PreAuthorize"] Z -->|enough rights| OK[Controller/service → 200/2xx] Z -->|not enough rights| F[403 JSON]

3. The boundaries of 401 and 403

One of the most common beginner mistakes in JWT projects sounds like this: “If access is forbidden, that means 403.” The problem is that 403 is already the second act of the play. In the first act, we must determine whether there is any authentication at all. If there is no authentication, or it failed (broken/expired token), we have not even reached the discussion about rights yet — that is 401. And only if the user is successfully restored can we discuss rights — and that is 403.

In our Secure Content Platform API project, this is especially important because the access zones are different. GET /api/public/articles should work normally for anonymous users. GET /api/me should require authentication. POST /api/editor/.../publish should require an authority like draft:publish. And if you start returning 403 for everything, the client will have no way to tell whether it needs to log in again and get a new token or whether it can log in but is not an editor.

From a practical point of view, this means the following. The JWT filter must not decide whether a draft can be published. It should only decide whether this request has a valid user. And access rules (request-level and method-level) already decide whether something is allowed. That is exactly why we built Authentication and SecurityContext in previous lectures: so that the authorization part works on standard Spring Security mechanisms, not on custom if statements inside the filter.

4. Categories of token problems

If we want to handle expired and malformed tokens cleanly, we need a way to distinguish the causes. Yes, we can catch “some JWT library exception,” but then the code quickly turns into “guess the exception class from the source.” Here it is better to introduce a thin abstraction layer: our TokenValidationException exception + reason (enum). It is like using sensible variable names instead of a1, a2: it does not make you smarter, but it makes the code tolerable to live with.

Let us start with an enum of reasons — short and readable:

package com.example.securecontent.security.jwt;

public enum TokenProblem {
    // Token is missing (for example, the Authorization header is absent or it is not Bearer)
    MISSING,
    // Token exists, but it is broken / forged / cannot be parsed / signature does not match
    MALFORMED,
    // Token is valid in format and signature, but the lifetime has expired
    EXPIRED
}

Now the exception that will be thrown by TokenService (or a helper around it). Note: we are not required to hide the original cause forever, but we will not expose it outside (in HTTP):

package com.example.securecontent.security.jwt;

public class TokenValidationException extends RuntimeException {

    // Problem category — convenient for mapping to code/message in the HTTP response
    private final TokenProblem problem;

    public TokenValidationException(TokenProblem problem, String message) {
        super(message);
        this.problem = problem;
    }

    // Explicitly expose the problem type: the filter will decide what exactly to return to the client
    public TokenProblem problem() {
        return problem;
    }
}

In practice, MISSING is often handled even before TokenService, when resolveBearerToken(...) returned null. But keeping one terminology is still convenient: the filter, the logs, and the responses all talk about the same states.

And this is where it is important not to overcomplicate things. In a learning project, these three categories are enough for us. “Wrong signature,” “wrong algorithm,” “payload is not JSON” — for the client and for our API semantics, this is all one and the same group: the token cannot be used, which means 401.

5. Returning 401 as JSON from the filter

When a token is broken, the filter has a choice. It can let the request continue with an empty context and hope that Spring Security will return 401 on .authenticated(). That is a workable approach, but it loses an important thing: if the client sent a Bearer header, it did try to authenticate, and we want to respond in a controlled way, not “whatever happens.” So here it is useful to make the behavior explicit: if the Bearer header is present, but the token is invalid or expired, we close the request right in the filter, returning a single JSON 401.

To avoid building JSON through string concatenation (which is fun until the first double quote in the message), let us create a small DTO for the response. Let it match our common error style: code, message, plus useful fields path and timestamp.

package com.example.securecontent.common.error;

import java.time.Instant;

public record ApiErrorResponse(
        // Machine-readable error code (useful for client-side handling)
        String code,
        // Human-readable message (short and safe for the outside world)
        String message,
        // Request path where the error happened
        String path,
        // Response creation time (useful for log correlation)
        Instant timestamp
) {
}

Now the filter needs a small function that writes the response. Ideally this is a separate component, but even in a compact version it is better to keep the logic separate and transparent. We will use ObjectMapper — it is already available in Spring Boot as a bean.

import com.example.securecontent.common.error.ApiErrorResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;

import java.io.IOException;

private void writeJson(HttpServletResponse response, ApiErrorResponse body) throws IOException {
    // Always return 401, because this is an authentication failure (not a business-logic failure)
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

    // In a REST API we return JSON, not HTML and not a redirect to the login page
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);

    // objectMapper is a Spring bean; it serializes the DTO to JSON and writes it to the output stream
    objectMapper.writeValue(response.getOutputStream(), body);
}

Pay attention to the details: we do not call println(), we do not print stack traces, we do not return HTML, we do not redirect to the login page (this is a REST API, not a browser flow), and we do not write “signature mismatch at byte 17” into the response — the client does not need that, and an attacker might enjoy it.

Now to the very center of the lecture: “how do we combine the happy path and failure path in one filter.” Let us assume we already have the methods resolveBearerToken(...), authenticateAndSetContext(...), and setAuthentication(...), which you wrote earlier. We will add try/catch and return 401.

import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
        throws java.io.IOException, jakarta.servlet.ServletException {

    // 1) Try to extract the Bearer token from the Authorization header
    String token = resolveBearerToken(request);

    // 2) If there is no token, this is the normal flow (public endpoints / anonymous), just continue
    if (token == null) {
        chain.doFilter(request, response);
        return;
    }

    try {
        // 3) If the token exists, validate it and restore Authentication in the SecurityContext
        authenticateAndSetContext(token);

        // 4) Only after successful authentication do we pass control further down the filter chain
        chain.doFilter(request, response);
    } catch (TokenValidationException ex) {
        // 5) On any token problem, clear the context: ThreadLocal must not remain "dirty"
        SecurityContextHolder.clearContext();

        // 6) And return a unified 401 JSON response without letting the request reach authorization
        writeJson(response, toUnauthorizedError(request, ex));
    }
}

There are two important “small things” here that, in practice, solve half of the mysterious bugs. The first is return after chain.doFilter(...) in the token == null branch. If you forget it, you will land in the try/catch and start “authenticating null,” which leads to the philosophical question “what does Schrödinger’s JWT look like.” The second is SecurityContextHolder.clearContext() in catch: even if you are sure the context is empty, it is better to clear it explicitly. A ThreadLocal context is useful, but when it gets “dirty,” everyone suffers.

The only thing left is to implement toUnauthorizedError(...). We deliberately do not expose details, but we can distinguish EXPIRED from “everything else,” so the client understands “log in again” vs “you broke/substituted something.”

import com.example.securecontent.common.error.ApiErrorResponse;

import java.time.Instant;

private ApiErrorResponse toUnauthorizedError(HttpServletRequest request, TokenValidationException ex) {
    // Expose safe codes to the outside world: they are useful to the client and do not reveal validation internals
    String code = (ex.problem() == TokenProblem.EXPIRED) ? "TOKEN_EXPIRED" : "INVALID_TOKEN";

    // Keep the message short too: do not return technical details to the client
    String message = (ex.problem() == TokenProblem.EXPIRED) ? "Token expired" : "Invalid token";

    // path and timestamp help both the client and the developer during debugging
    return new ApiErrorResponse(code, message, request.getRequestURI(), Instant.now());
}

A few words about logging, because this is a classic self-inflicted wound. You cannot log the full Authorization header: that is effectively leaking the token into logs. If you really need to log something, log only the event and the path, and at most a safe “piece” of the token.

private String tokenPreview(String token) {
    // Do not log the token in full: even in a learning project this is a bad habit
    if (token.length() < 12) return "***";

    // Show only the first characters so events can be correlated in logs
    return token.substring(0, 8) + "...";
}

You can use this line in logs if you really want to, but even then it is better not to overdo it. A token is not a password, but it is still a secret that opens the door.

6. Full request flow: from login to 403 on editor/admin

Now let us put the whole picture together so it does not remain a collection of “separate smart methods.” In our branch of the project, login already exists: POST /api/auth/login authenticates the user through AuthenticationManager and returns an access token. After that, the client must put this token in Authorization on every request to a protected endpoint. And here our filter becomes the “guard at the turnstile.”

Below is a sequence diagram without unnecessary romance, but with a clear sequence of events:

sequenceDiagram
    participant Client as API Client
    participant Login as /api/auth/login
    participant Chain as SecurityFilterChain
    participant JwtF as JwtAuthFilter
    participant AuthZ as "Authorization (rules + method security)"
    participant Api as Controller/Service

    Client->>Login: "POST /api/auth/login (username+password)"
    Login-->>Client: 200 { accessToken: "..." }

    Client->>Chain: GET /api/me + Authorization: Bearer ...
    Chain->>JwtF: doFilterInternal()
    JwtF->>JwtF: validate token (exp/signature)
    JwtF->>JwtF: build Authentication + set SecurityContext
    JwtF-->>Chain: continue

    Chain->>AuthZ: check authenticated / hasAuthority
    AuthZ-->>Api: ok
    Api-->>Client: 200 { userId, username }

    Client->>Chain: POST /api/editor/drafts/10/publish + Bearer(USER token)
    Chain->>JwtF: restore Authentication
    Chain->>AuthZ: "hasAuthority('draft:publish')?"
    AuthZ-->>Client: 403 JSON (forbidden)

And now the key difference that you should literally feel in your skin, like the cold air from an office air conditioner: if the token is invalid/expired, the request must not “crawl” to the rights check. It must end earlier — 401. And if the token is valid but the rights are missing — that is already 403.

It is very useful to test this manually in the project. For example, such a request without a token to /api/me should become 401:

curl -i http://localhost:8080/api/me
# HTTP/1.1 401
# {"code":"UNAUTHORIZED", ...}  (format depends on your contract)

And such a request with a valid token for a regular user in the editor zone should become 403:

curl -i -H "Authorization: Bearer <user_token>" \
  -X POST http://localhost:8080/api/editor/drafts/10/publish
# HTTP/1.1 403
# {"code":"FORBIDDEN", ...}

If you are somehow getting “401 all the time,” then you are not restoring Authentication or not placing it into SecurityContext. If you are getting “403 all the time,” then you are confusing lack of authentication with lack of rights, or your JSON error handler is configured incorrectly.

7. Public/login endpoints and shouldNotFilter(...)

The same responsibility boundary is also easy to express through shouldNotFilter(...). It makes the doFilterInternal(...) code cleaner: it deals with the token, not with routing.

For our project, the rule is usually simple: do not filter the public zone, auth endpoints, and anything that is not /api/** at all. For such a basic scope, this is enough and does not require complicated matcher combinations.

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.filter.OncePerRequestFilter;

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
    // The less the filter knows about routing, the easier it is to maintain and debug
    String uri = request.getRequestURI();

    // Do not touch anything outside the API, the public zone, and auth endpoints:
    // there the Bearer token is either not needed or does not exist yet
    return !uri.startsWith("/api/")
            || uri.startsWith("/api/public/")
            || uri.startsWith("/api/auth/");
}

Why is this important specifically for error handling? Because without such a restriction you can easily get a “it ate itself” scenario: you call an auth endpoint, there is no token yet (it is only about to be issued), the filter starts getting nervous, and you try to authenticate a request that is not supposed to be a bearer request at all. It is like the situation “to enter the system, show your pass; to get a pass, enter the system.” Beautiful, but useless.

8. Common mistakes when handling missing/expired/malformed token

Mistake #1: returning 500 when the token is broken.
This happens when an exception from TokenService escapes and reaches the global exception handler as an “unexpected server error.” The client sees 500, thinks “the server crashed,” and you start fixing what is not broken. An invalid/expired token is a normal client-side situation; it should turn into a controlled 401 with a clear JSON response.

Mistake #2: mixing up 401 and 403 and making everything 403.
If you answer 403 for a missing token, the client cannot know that it needs to “log in again.” In the JWT model this is especially critical: an expired token is 401, missing rights is 403. When these codes are mixed, even a correct client starts behaving strangely, and you get a “magical bug” that can only be fixed by restarting Postman.

Mistake #3: logging the token (or the entire Authorization header).
Sometimes this is done “temporarily, for debugging,” and then forgotten. As a result, tokens live in logs, logs live in the log aggregator, half the company has access to the aggregator, and suddenly the token is no longer secret. This is also important in a learning project: the habit of “logging everything” transfers very badly into real systems.

Mistake #4: trying to do authorization inside the JWT filter.
The filter should restore the user and their authorities, but not decide whether a draft can be published or whether this is your own draft or someone else’s. Those rules already live in request-level configuration and in method security. If you start injecting business checks into the filter, you get code that is hard to test, hard to maintain, and very easy to accidentally bypass.

Mistake #5: not clearing the SecurityContext on error and leaving a “dirty context.”
In theory, “the request will fail anyway,” but in practice it happens that you partially created Authentication, threw an exception somewhere, and forgot to clear the context. Then some downstream code sees “looks like there is a user” and starts behaving unpredictably. An explicit SecurityContextHolder.clearContext() in the failure branch is a small price for a lot of predictability.

1
Task
Spring Security, level 24, lesson 4
Locked
JSON `401` for `expired` and `malformed` token
JSON `401` for `expired` and `malformed` token
1
Task
Spring Security, level 24, lesson 4
Locked
Full request flow from login to `401` and `403`
Full request flow from login to `401` and `403`
1
Survey/quiz
JWT Authentication, level 24, lesson 4
Unavailable
JWT Authentication
Stateless token-based access
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION