Spring Profile で dev だけ認証を緩める設計 — prod の構造を壊さない最小限の Filter
この記事でわかること
- 本物の JWT 検証を入れる前に「認証通った前提」で API を動かす最小限の方法
- Spring の
@Profile("dev")+ObjectProviderで prod 側のコードを汚さない Filter 差し込みパターン - prod の Resource Server に切り替える時に何を消すだけで済むようにしておくか
対象読者
- Spring Boot で PoC を作りながら、本番化のときに認証構造を壊さない設計を考えたい方
SecurityFilterChainに dev 専用 Filter を差し込む書き方を知りたい方- 「dev だけ if 分岐」を Service ではなく Configuration に閉じ込めたい方
動作環境
| 項目 | バージョン |
|---|---|
| Java | 21 |
| Spring Boot | 3.4.5 |
| Spring Security | 6.x |
シリーズ記事(開発進行中) — この記事は 街中の電動キックボードシェアリングを自作して理解する — 設計・実装・運用の記録シリーズ の一部です。プロジェクトは現在も継続中で、新しい記事や設計判断の追記が随時行われます。プロジェクト全体の動機・採用技術・画面IDなどの用語表・他記事への索引はリンク先にまとめています。
はじめに
電動キックボードシェアリングアプリで E2E を通そうとした時、ログイン以降の API が 全部 401 で弾かれていました。

実装を確認すると:
AuthService.verifyOtpは OTP123456で成功し、dummy-jwt-token-for-userid-{UUID}という文字列を返す- でも
SecurityConfig側に その文字列を解釈する Filter が無い - 結果として
/users/me /payments/** /ekyc/** /rentals/**などは.anyRequest().authenticated()で全部 401
本番化のときは Spring Security OAuth2 Resource Server を入れて本物の JWT を署名検証する設計でしたが、PoC のためにそこまで踏み込むと時間が溶けます。
そこで採った戦略が、dev profile 限定の Filter を1枚だけ足す こと。
何を割り切ったか
本物の JWT 検証は以下を満たす必要があります。
- 署名検証(HS256 / RS256 など)
- 有効期限チェック(
expクレーム) - 発行者チェック(
issクレーム) - スコープ・ロール抽出
PoC でこれらは要りません。User ID が取り出せて Authentication として SecurityContext に入れば、業務ロジックが回り始める からです。
割り切るのはこの一点に絞りました。
最小実装
@Profile("dev") をつけた OncePerRequestFilter を1枚追加します。
@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);
}
}
要点:
- 正規表現でトークン形式を厳格に縛る(任意の文字列を渡して認証を通すことはない)
principalを UUID 文字列 にすることで、CurrentUserProviderがauthentication.getName()から UUID として読み取れる- 権限リストは空 (
Collections.emptyList())。dev では権限差別化が要らない
SecurityConfig 側の差し込み
ここが今回いちばん工夫したところです。
普通に @Autowired private DevDummyJwtAuthFilter devFilter; と書くと、prod 起動時に「Bean が存在しないため起動失敗」になります。@Autowired(required = false) でもいいですが、より明示的に 存在しないかもしれない Bean を扱う 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()
);
// dev profile が有効な場合のみ、ダミーJWT認証フィルタを差し込む。
// prod ではこの Bean が存在しないため何も追加されない。
DevDummyJwtAuthFilter devFilter = devDummyJwtAuthFilterProvider.getIfAvailable();
if (devFilter != null) {
http.addFilterBefore(devFilter, UsernamePasswordAuthenticationFilter.class);
}
return http.build();
}
}
ポイント:
- dev/prod の判定が「Bean が存在するかどうか」に集約されている。
environment.acceptsProfiles(...)のような明示判定が要らない - prod では
DevDummyJwtAuthFilter自体が@Profile("dev")で生成されないので、getIfAvailable()はnullを返す →addFilterBeforeが呼ばれない - chain の他の構成(permitAll の対象、
anyRequest().authenticated())は dev/prod 共通
CurrentUserProvider との連携
業務ロジックは Authentication から UUID を取り出すために 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 でも prod でも、この requireUserId の入力は Authentication。
- dev:
DevDummyJwtAuthFilterがダミー JWT から UUID を抽出してセット - prod: OAuth2 Resource Server などが署名検証済み JWT から UUID を抽出してセット
CurrentUserProvider は両方とも同じシグネチャで動くので、業務ロジックは dev/prod の違いを知らずに済みます。

スクショの customer_id: cus_mock_... と client_secret: seti_mock_... は API 側の Service が dev で発行している値。Authorization ヘッダのダミー JWT が Filter で解釈されて UserId 取得 → Service が呼ばれた という証跡になっています。
落とし穴:JSR-305 @NonNull
doFilterInternal を override する時、Spring 6 の親クラスがパラメータに @NonNull を要求しています。
Missing non-null annotation: inherited method from OncePerRequestFilter
specifies this parameter as @NonNull
警告だけでビルドは通りますが、気になるなら org.springframework.lang.NonNull を import してパラメータに付ければ消えます。
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain chain) throws ServletException, IOException {
...
}
ちなみに IDE のキャッシュが古いと、新しい @NonNull import が一時的に「使われていない」と誤検知することがあります。コンパイルが通っていれば気にしなくて OK。
prod 切替時に何を消すだけで済むようにしたか
PoC が動いた後、本番化するときの作業は以下に絞られます。
DevDummyJwtAuthFilter.javaを削除(または@Profile("dev")のまま残して prod では Bean が作られない状態にしておく)SecurityConfigにoauth2ResourceServer(...).jwt(...)を追加し、本物の JWT 検証を入れるAuthServiceImpl.verifyOtpで発行する JWT を 本物の jjwt 等で署名する ように差し替える
CurrentUserProvider も Controller も Service も触らずに済む。これが、dev profile の分岐を Filter / Configuration の層に閉じ込めた ご利益です。
学び
- dev/prod の if 分岐は Service よりも上のレイヤー(Filter / Configuration)に置く と、業務ロジックが両プロファイル共通で済む
@Profile("dev")+ObjectProviderの組み合わせは、prod 起動時に余計な Bean を作らないし、Configuration 側でnullチェック1行で扱える- 「PoC で割り切ったコード」が「本番化のときに何を消せば良いか」を最初から決めておくと、移行が機械的になる
PoC の認証は、本物の信頼境界の前で 「ここから先は同じインタフェースで叩ける」状態を作る ためのワンクッションだと割り切るのが現実的でした。