Relaxing Authentication Only in dev with a Spring Profile — A Minimal Filter That Doesn't Disturb the prod Structure
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")+ObjectProviderto 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
| Item | Version |
|---|---|
| Java | 21 |
| Spring Boot | 3.4.5 |
| Spring Security | 6.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.

Looking at the implementation:
AuthService.verifyOtpsucceeds on OTP123456and returns the stringdummy-jwt-token-for-userid-{UUID}- But
SecurityConfighas 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 (
expclaim) - Issuer check (
issclaim) - 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
principalis set to a UUID string, soCurrentUserProvidercan read it back viaauthentication.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,
DevDummyJwtAuthFilterisn’t created (it has@Profile("dev")), sogetIfAvailable()returnsnullandaddFilterBeforeis 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:
DevDummyJwtAuthFilterextracts 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.

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:
- Delete
DevDummyJwtAuthFilter.java(or leave it with@Profile("dev")— prod won’t instantiate it anyway) - Add
oauth2ResourceServer(...).jwt(...)toSecurityConfigfor real JWT verification - Swap
AuthServiceImpl.verifyOtpto 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")+ObjectProvidercombo doesn’t create extra beans in prod and lets Configuration handle the optional case with a singlenullcheck - 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.