JWT: 正しい使い方とよくあるセキュリティ脆弱性

1. 概要

JWT(JSON Web Token)はAPI認証に広く使われていますが、実装を誤るとセキュリティの重大な脆弱性につながるシステムが多数あります。この記事では、Spring BootでのJWTの正しい作成・検証方法と、典型的なセキュリティ脆弱性(algのconfusion、弱いsecret、有効期限の未検証など)と対策をまとめています。この記事を読み終わると、JWTを安全に設定する方法と、同僚のJWTコードを自信を持ってレビューできるようになります。

2. JWTの構造と動作メカニズム

JWTは3つの部分で構成されています: header.payload.signatureで、base64urlでエンコードされ、ドットで区切られています。

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.4Z... (signature)
  • Header: alg(署名アルゴリズム)とtypが含まれます。
  • Payload: sub、exp、iat、rolesなどのclaimsが含まれます。
  • Signature: トークンが改ざんされていないことを検証するための署名。

注意: JWT はペイロードを暗号化するのではなく、エンコードするだけです。誰でもトークンがあればコンテンツを読むことができます。パスワードや機密性の高いPIIをclaimsに詰め込まないでください。

3. 依存関係の追加

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>

jjwt(io.jsonwebtoken)ライブラリはJavaで最も一般的で、APIが明確で、デフォルトでよくある脆弱性をいくつか防いでいます。

4. トークンの正しい作成と検証

4.1. 十分に強いsecret keyの生成

import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;

public class JwtKeyFactory {

    public static SecretKey generateKey() {
        return Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
    }
}

Keys.secretKeyForはHS256に必要な十分な長さのランダムキーを生成します(≥ 256ビット)。本番環境では、このキーをsecret managerに保存してください(Vault、AWS Secrets Manager)。コードにハードコードしないでください。

4.2. TokenServiceでトークンを作成する

import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;
import java.time.Instant;
import java.util.Date;

public class TokenService {

    private final SecretKey key;

    public TokenService(SecretKey key) {
        this.key = key;
    }

    public String generateToken(String username) {
        Instant now = Instant.now();
        return Jwts.builder()
                .subject(username)
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plusSeconds(900))) // 15分
                .signWith(key)
                .compact();
    }
}
  • expirationは15分に設定されます。アクセストークンは短い有効期限を持つべきです。
  • signWith(key)はキーのタイプに適切なalgを自動的に選択し、クライアントがalgに干渉するのを防ぎます。

4.3. トークンの検証

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;

public class TokenVerifier {

    private final SecretKey key;

    public TokenVerifier(SecretKey key) {
        this.key = key;
    }

    public Claims verify(String token) {
        try {
            return Jwts.parser()
                    .verifyWith(key)
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();
        } catch (JwtException e) {
            throw new InvalidTokenException("トークンが無効またはすでに期限切れです", e);
        }
    }
}

parseSignedClaimsは署名とexpを自動的に検証します。署名が誤っているか、トークンの有効期限が切れている場合、JwtExceptionがスローされます。これを独自のドメイン例外にキャッチしてください。

5. セキュリティ脆弱性 #1: alg=noneまたはalgのconfusion

攻撃者はヘッダーを{"alg":"none"}に変更し、署名のないトークンを送信できます。バックエンドがアルゴリズムを強制しない場合、偽造されたトークンを受け入れるかもしれません。

// 間違い: 許可されたアルゴリズムを指定していない
Jwts.parser().build().parseSignedClaims(token); // 古いライブラリは騙されやすい

対策 — 常にアルゴリズムをホワイトリストに追加する:

import io.jsonwebtoken.Jwts;

Jwts.parser()
        .verifyWith(key)          // 既知のキーだけを受け入れる
        .build()
        .parseSignedClaims(token); // jjwt 0.12+はalg=noneを自動的に防ぐ

注意: 他のJWTライブラリ(jose4j、nimbus-jose-jwt)の場合、常にアルゴリズムをホワイトリストに明示的に設定してください(例:HS256のみを許可)。クライアントから送信されたトークンのalgフィールドを決して信頼しないでください。

もっと危険なバリエーション: RS256とHS256の間のalgのconfusion。サーバーがRSAの公開鍵で検証するが、alg=HS256の場合にそれをHMACのシークレットとして使用すると、攻撃者は公開鍵(公開されている)でトークンに署名できます。解決策:ユースケースに応じてアルゴリズムを固定し、トークンからalgを自動的に読み取ってそれに応じた検証方法を選択するコードを避けてください。

6. セキュリティ脆弱性 #2: 弱いシークレットまたはシークレットの漏洩

// 間違い: シークレットが短く、ブルートフォース攻撃しやすい
String secret = "mysecret123";
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes());

HS256は≥256ビット(32バイト)のキーが必要です。短いシークレットはhashcatのようなツールでオフラインでブルートフォース攻撃される可能性があり、数時間で破られることがあります。

// 正しい: シークレットは十分な長さで、ランダム
SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);

その他:

  • Gitにシークレットをコミットしないでください。環境変数またはsecret managerを使用してください。
  • シークレットを定期的にローテーションしてください。特に、シークレットへのアクセス権を持つスタッフが退職した後はそうしてください。
# application.properties - 環境変数から読み込み、ハードコードしない
jwt.secret=${JWT_SECRET}

7. セキュリティ脆弱性 #3: 有効期限の未検証またはトークンの有効期限が長すぎる

// 間違い: アクセストークンが30日間有効
.expiration(Date.from(now.plus(30, ChronoUnit.DAYS)))

アクセストークンの有効期限が長すぎると、トークンが漏洩した場合、攻撃者が長期間アクセスでき、あなたはそれを取り消すことができません(JWTはステートレスで、デフォルトではログアウトメカニズムがないため)。

正しいパターン: アクセストークンは短い(5〜15分)+ より長いrefresh token、サーバーに保存(またはローテーション)して取り消し可能にする。

public class TokenPair {
    private final String accessToken;   // 15分間有効
    private final String refreshToken;  // 7日間有効、取り消し可能にするためDB保存

    // constructor, getters
}
public class RefreshTokenRepository {

    public boolean isRevoked(String refreshTokenId) {
        // DB/Redisをクエリ、トークンがログアウトされている場合はtrueを返す
        return false;
    }
}

リフレッシュトークンが取り消された場合、古いアクセストークンは有効期限が切れるまで有効です。これがアクセストークンを短くする必要がある理由です。

8. セキュリティ脆弱性 #4: 機密データをペイロードに詰め込む

// 間違い: ペイロードは公開で、トークンがあれば誰でも読める
.claim("password", user.getPassword())
.claim("creditCard", user.getCardNumber())

ペイロードはbase64-エンコードされているだけで、暗号化されていません。jwt.ioでデコードするだけで全部見えます。認可に必要なclaimsだけに限定してください: sub、roles、exp、scope。

// 正しい
.subject(user.getUsername())
.claim("roles", user.getRoles())

9. セキュリティ脆弱性 #5: 複数のサービスで同じシークレットを使用する場合、発行者/対象者の検証がない

複数のサービスが同じシークレットでトークンに署名する場合、Service Aのために発行されたトークンは、aud(対象者)をチェックしないとService Bに対してリプレイされる可能性があります。

import io.jsonwebtoken.Jwts;

Claims claims = Jwts.parser()
        .verifyWith(key)
        .requireAudience("order-service") // このサービスのためのトークンのみ受け入れる
        .requireIssuer("auth-server")
        .build()
        .parseSignedClaims(token)
        .getPayload();

requireAudienceとrequireIssuerはclaimが一致しない場合、parserに例外をスローするよう強制します。これによって、トークンが間違った目的で使用されるのを防げます。

10. Spring Securityフィルターとの統合

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

public class JwtAuthFilter extends OncePerRequestFilter {

    private final TokenVerifier verifier;

    public JwtAuthFilter(TokenVerifier verifier) {
        this.verifier = verifier;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) throws IOException, ServletException {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            try {
                var claims = verifier.verify(header.substring(7));
                var auth = new UsernamePasswordAuthenticationToken(
                        claims.getSubject(), null, List.of());
                SecurityContextHolder.getContext().setAuthentication(auth);
            } catch (InvalidTokenException e) {
                SecurityContextHolder.clearContext();
            }
        }
        chain.doFilter(request, response);
    }
}
  • Authorization: Bearer <token>ヘッダーからトークンを取得します。
  • セクション4.3で記述したTokenVerifierを使って検証します。
  • トークンが無効な場合、コンテキストをクリアします。リクエストは後の承認ステップでSpring Securityによってブロックされ、フィルター内で直接例外がスローされることはありません。

注意: フロントエンドがパブリックなSPAの場合、JWTをlocalStorageに保存しないでください。XSSで盗まれるリスクがあります。可能であれば、HttpOnly、Secure、SameSite=Strictのクッキーに保存することをお勧めします。

11. トークン検証の動作をテストする

import io.jsonwebtoken.security.Keys;
import org.junit.jupiter.api.Test;
import javax.crypto.SecretKey;
import java.time.Instant;

import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;

class TokenVerifierTest {

    private final SecretKey key = Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
    private final TokenService tokenService = new TokenService(key);
    private final TokenVerifier verifier = new TokenVerifier(key);

    @Test
    void shouldVerifyValidToken() {
        String token = tokenService.generateToken("alice");

        var claims = verifier.verify(token);

        assertThat(claims.getSubject()).isEqualTo("alice");
    }

    @Test
    void shouldRejectTokenSignedWithDifferentKey() {
        SecretKey otherKey = Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
        TokenService fakeService = new TokenService(otherKey);
        String forgedToken = fakeService.generateToken("attacker");

        assertThatThrownBy(() -> verifier.verify(forgedToken))
                .isInstanceOf(InvalidTokenException.class);
    }
}

2番目のテストは、攻撃者が異なるキーでトークンに署名する状況を正確にシミュレートしています。verifierはそれを拒否する必要があり、黙って受け入れてはいけません。

shouldVerifyValidToken()                 PASS
shouldRejectTokenSignedWithDifferentKey() PASS

12. デプロイ前のクイックチェックリスト

  • シークレット≥256ビット、secret managerに保存、ハードコードしない。
  • アルゴリズムをホワイトリストに追加、トークンのalgを信じない。
  • アクセストークンは短い(≤15分)、取り消し可能なリフレッシュトークンがある。
  • 機密データをペイロードに詰め込まない。
  • 必要に応じてexp、iss、audを検証する。
  • フロントエンドがSPAの場合、localStorageの代わりにHttpOnlyクッキーにトークンを保存。
  • リフレッシュトークンの取り消し/ブラックリストメカニズムがある。

13. 結論

JWT自体に欠陥はありません。ほとんどの問題は実装から来ています。シークレットが弱い、クライアントのalgを信頼する、トークンの有効期限が長すぎる、または公開ペイロードに機密データを詰め込むなどです。セクション12のチェックリストを適用し、常にテスト済みのJWTライブラリ(jjwt、nimbus-jose-jwt)を使用し、パーサーを自作しなければ、よくある脆弱性の大部分を避けることができます。次に学ぶ価値があるのは、複数のサービス規模でのより良いトークンライフサイクル管理のためのOAuth 2.0 / OpenID Connectです。

jwtweb-securityspring-bootauthenticationjava