Tech Blog

Relaxing Authentication Only in dev with a Spring Profile — A Minimal Filter That Doesn't Disturb the prod Structure

by Tech Writer
Spring Security Spring Boot Java Profile JWT PoC Design

What You’ll Learn

  • A minimal way to run APIs “as if authenticated” before plugging in real JWT verification
  • A pattern that uses Spring’s @Profile("dev") + ObjectProvider to inject filters without dirtying the prod side
  • What to leave so that switching to a prod Resource Server is a delete-only operation

Target Audience

  • Anyone building a PoC on Spring Boot who wants to keep the auth structure intact when going to production
  • Developers who want to know how to insert a dev-only filter into SecurityFilterChain
  • People who want “dev-only if branches” confined to Configuration rather than Service

Environment

ItemVersion
Java21
Spring Boot3.4.5
Spring Security6.x

Series article (in active development) — This article is part of Building Street-Level E-Scooter Sharing From Scratch to Understand It — Design, Implementation, and Operations Series. The project is ongoing; new articles and design notes are added continuously. The series motivation, technology stack, screen-ID glossary, and the full article index are collected at the link above.

Introduction

When trying to push the e-scooter sharing app through E2E, all post-login APIs returned 401.

S05 login screen — public endpoints don't return 401 here

Looking at the implementation:

  • AuthService.verifyOtp succeeds on OTP 123456 and returns the string dummy-jwt-token-for-userid-{UUID}
  • But SecurityConfig has no filter that interprets that string
  • So /users/me /payments/** /ekyc/** /rentals/** all get rejected by .anyRequest().authenticated() with 401

In production we plan to use Spring Security’s OAuth2 Resource Server for real JWT signature verification, but doing that for a PoC eats time.

The chosen strategy: add a single dev-profile-only filter.


What We Compromised On

Real JWT verification needs:

  • Signature verification (HS256 / RS256, etc.)
  • Expiration check (exp claim)
  • Issuer check (iss claim)
  • Scope / role extraction

None of these are needed in a PoC. What matters is that a User ID gets pulled out and dropped into the SecurityContext as an Authentication — that’s when the business logic starts spinning.

We narrowed the compromise to that single point.


Minimal Implementation

A OncePerRequestFilter marked with @Profile("dev").

@Component
@Profile("dev")
@Slf4j
public class DevDummyJwtAuthFilter extends OncePerRequestFilter {

    private static final String BEARER_PREFIX = "Bearer ";
    private static final Pattern DUMMY_TOKEN_PATTERN =
            Pattern.compile("^dummy-jwt-token-for-userid-([0-9a-fA-F\\-]{36})$");

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain chain) throws ServletException, IOException {
        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
            String token = authHeader.substring(BEARER_PREFIX.length()).trim();
            Matcher matcher = DUMMY_TOKEN_PATTERN.matcher(token);
            if (matcher.matches()) {
                String userId = matcher.group(1);
                Authentication auth = new UsernamePasswordAuthenticationToken(
                        userId, null, Collections.emptyList());
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        }
        chain.doFilter(request, response);
    }
}

Key points:

  • A regex pins the token format tightly (no arbitrary string can authenticate)
  • The principal is set to a UUID string, so CurrentUserProvider can read it back via authentication.getName()
  • Authorities list is empty (Collections.emptyList()). dev doesn’t need role differentiation

Wiring into SecurityConfig

This is where we put the most thought.

A naive @Autowired private DevDummyJwtAuthFilter devFilter; will fail prod startup (“bean does not exist”). @Autowired(required = false) would work, but to be more explicit about “a bean that may or may not exist” we used ObjectProvider.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final ObjectProvider<DevDummyJwtAuthFilter> devDummyJwtAuthFilterProvider;

    public SecurityConfig(ObjectProvider<DevDummyJwtAuthFilter> devDummyJwtAuthFilterProvider) {
        this.devDummyJwtAuthFilterProvider = devDummyJwtAuthFilterProvider;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/v1/ports/**").permitAll()
                        .requestMatchers("/api/v1/scooters/*/status").permitAll()
                        .requestMatchers("/api/v1/auth/send-otp", "/api/v1/auth/verify-otp").permitAll()
                        .requestMatchers("/actuator/health", "/actuator/info").permitAll()
                        .anyRequest().authenticated()
                );

        // Only insert the dummy JWT filter when dev profile is active.
        // In prod the bean is absent, so nothing is added.
        DevDummyJwtAuthFilter devFilter = devDummyJwtAuthFilterProvider.getIfAvailable();
        if (devFilter != null) {
            http.addFilterBefore(devFilter, UsernamePasswordAuthenticationFilter.class);
        }

        return http.build();
    }
}

Strengths:

  • dev/prod selection is reduced to “does the bean exist?” No explicit environment.acceptsProfiles(...) check needed
  • In prod, DevDummyJwtAuthFilter isn’t created (it has @Profile("dev")), so getIfAvailable() returns null and addFilterBefore is never called
  • The rest of the chain (permitAll targets, anyRequest().authenticated()) is shared between dev and prod

Connecting with CurrentUserProvider

Business logic extracts the UUID from Authentication via CurrentUserProvider.

@Component
public class CurrentUserProvider {

    public UUID requireUserId(Authentication authentication) {
        if (authentication == null || !authentication.isAuthenticated()) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication is required");
        }
        String principalName = authentication.getName();
        if (principalName == null || principalName.isBlank() || "anonymousUser".equals(principalName)) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid authenticated principal");
        }
        try {
            return UUID.fromString(principalName);
        } catch (IllegalArgumentException ex) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated principal is not a valid UUID", ex);
        }
    }
}

Dev and prod both feed requireUserId the same Authentication:

  • dev: DevDummyJwtAuthFilter extracts the UUID from the dummy JWT and sets it
  • prod: the OAuth2 Resource Server extracts the UUID from a signature-verified JWT and sets it

CurrentUserProvider works identically for both, and the business logic never needs to know the difference.

S06 payment registration — authenticated endpoints pass through with 200 once the filter is in place

The customer_id: cus_mock_... and client_secret: seti_mock_... in the screenshot are values the dev-profile Service issues. They’re evidence that the Authorization header’s dummy JWT was interpreted by the filter, the UserId was extracted, and the Service was called.


Pitfall: JSR-305 @NonNull

When overriding doFilterInternal, Spring 6’s parent class requires @NonNull on the parameters.

Missing non-null annotation: inherited method from OncePerRequestFilter
specifies this parameter as @NonNull

It’s just a warning — the build passes. But if it bothers you, import org.springframework.lang.NonNull and add it to the parameters.

@Override
protected void doFilterInternal(
        @NonNull HttpServletRequest request,
        @NonNull HttpServletResponse response,
        @NonNull FilterChain chain) throws ServletException, IOException {
    ...
}

Note: if your IDE cache is stale, a freshly added @NonNull import can be flagged as “unused” temporarily. Ignore it as long as the compiler is happy.


What Can Be Deleted to Go Prod

After the PoC works, productionization shrinks to:

  1. Delete DevDummyJwtAuthFilter.java (or leave it with @Profile("dev") — prod won’t instantiate it anyway)
  2. Add oauth2ResourceServer(...).jwt(...) to SecurityConfig for real JWT verification
  3. Swap AuthServiceImpl.verifyOtp to issue real signed JWTs via jjwt or similar

CurrentUserProvider, controllers, and services all stay untouched. That’s the payoff for confining dev profile branches to the Filter / Configuration layer.


Lessons

  • Put dev/prod if branches higher than the Service layer (Filter / Configuration) so business logic stays profile-agnostic
  • The @Profile("dev") + ObjectProvider combo doesn’t create extra beans in prod and lets Configuration handle the optional case with a single null check
  • Decide upfront what gets deleted at productionization — the migration becomes mechanical

PoC authentication is a buffer that creates a “from here on, you can use the same interface” boundary in front of the real trust boundary. Treating it that way is the practical move.

Feel free to send a message

Please send a message if you have any technical questions, feedback, or inquiries.