JWT実装を見返したら冷や汗が出た話——OAuth運用4年で踏んだ地雷まとめ

「あのとき被害が出なかったのは運だった」——alg:none攻撃の見落とし、Implicit Flowの負債、リフレッシュ設計の失敗。4年の本番運用で実際に踏んだ地雷と、2026年時点での回避策を書きました。

先日チームのセキュリティレビューで、2年前に自分が書いたJWT実装を見返したら冷や汗が出た。署名アルゴリズムの選定が甘くて、alg: none攻撃のリスクが普通に残ってた。幸い本番被害は出なかったけど、「あのとき何も起きなかったのは運だった」と今は思う。

OAuthとJWT周りは情報が多い割に、「実際の本番でどこを踏み外すか」の知見が少ない。教科書的な解説は探せばいくらでも出てくるけど、4年運用して気づいた「地雷の踏み方と回避策」を今日は書いていく。


OAuth 2.1 + PKCE が2026年の標準になった理由

OAuth 2.1は2026年前半にRFC 9700として正式化され、ようやくエンタープライズでも「まずこれ」という共通認識になってきた。一番大きい変化はImplicit Flowの正式廃止と、PKCEの全フローへの必須化だ。

「SPAだからImplicit Flowでいいでしょ」で実装してたサービス、まだ結構残ってるんじゃないかと思う。うちも2022年のサービス立ち上げ時はその構成で、2024年に全部作り直した経験がある。正直、作り直しのコストはかなり痛かった。

PKCEの仕組みを簡単に確認しておくと、こういう流れになる。

sequenceDiagram
    participant Client
    participant AuthServer as 認可サーバー
    participant ResourceServer as リソースサーバー

    Client->>Client: code_verifier生成(ランダム43-128文字)
    Client->>Client: code_challenge = BASE64URL(SHA256(code_verifier))
    Client->>AuthServer: 認可リクエスト + code_challenge
    AuthServer->>Client: 認可コード
    Client->>AuthServer: トークンリクエスト + code_verifier
    AuthServer->>AuthServer: SHA256(code_verifier) == code_challenge を検証
    AuthServer->>Client: access_token + refresh_token
    Client->>ResourceServer: APIリクエスト + access_token
    ResourceServer->>Client: レスポンス

Node.jsでPKCEのcode_verifierとcode_challengeを生成するとこんな感じ。

import crypto from 'crypto';

// code_verifier生成
function generateCodeVerifier(): string {
  return crypto
    .randomBytes(64)
    .toString('base64url')
    .substring(0, 128);
}

// code_challenge生成(S256メソッド)
function generateCodeChallenge(codeVerifier: string): string {
  return crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64url');
}

// 使い方
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);

// セッションにcode_verifierを保存(サーバーサイド)
req.session.codeVerifier = codeVerifier;

// 認可リクエストURL構築
const authUrl = new URL('https://auth.example.com/oauth/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', process.env.CLIENT_ID!);
authUrl.searchParams.set('redirect_uri', 'https://app.example.com/callback');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', crypto.randomBytes(16).toString('hex'));

ここで地味に重要なのが state パラメータ。CSRFトークンとして機能するこのパラメータ、実装を省略してるケースを結構見る。code_challenge があるからOKと思いがちだけど、stateはリダイレクト先のなりすまし(オープンリダイレクト攻撃)への対策なので、必ず検証すること。個人的には「PKCEさえ入れれば安心」という油断が一番危ないと感じている。

セキュリティ実装の全体像については、OWASP Top 10 2024対策でも触れているので合わせて読んでほしい。特にA01のアクセス制御の破綻は、OAuth実装ミスと直結する。


JWTの設計ミスで本番を壊しかけた話

正直に言うと、JWT周りの実装は最初の2年間かなり甘かった。一番ヒヤリとしたのが alg ヘッダーの検証漏れ だ。

ライブラリによっては、JWTのヘッダーで "alg": "none" を指定すると署名検証をスキップしてしまうものがある(現在はほとんどのライブラリで修正済みだが、古いバージョンを使ってると要注意)。冒頭で書いた「2年前の自分の実装」がまさにこれで、当時は「ライブラリが面倒を見てくれる」と思い込んでいた。

2026年時点でのJWT設計の要点をまとめるとこうなる。

項目推奨避けるべき
署名アルゴリズムRS256, ES256(非対称)HS256(対称)をマイクロサービス間で共有
トークン有効期限アクセストークン: 5〜15分1時間以上
リフレッシュトークン有効期限7〜30日(用途による)無期限
alg検証必ずサーバー側で強制指定JWTヘッダーのalgを信頼
クレーム検証exp, iss, aud を必ず検証expだけ検証して終わり
保存場所(SPA)httpOnly CookielocalStorage

PythonでJWT検証する実装例はこんな感じ。

import jwt
from jwt.exceptions import InvalidTokenError
from typing import Optional
import time

class JWTValidator:
    def __init__(self, public_key: str, issuer: str, audience: str):
        self.public_key = public_key
        self.issuer = issuer
        self.audience = audience
    
    def validate(self, token: str) -> Optional[dict]:
        try:
            # アルゴリズムを明示的に指定(RS256のみ許可)
            payload = jwt.decode(
                token,
                self.public_key,
                algorithms=["RS256"],  # alg noneを防ぐ
                options={
                    "require": ["exp", "iss", "aud", "iat"],
                    "verify_exp": True,
                    "verify_iss": True,
                    "verify_aud": True,
                },
                issuer=self.issuer,
                audience=self.audience,
                leeway=30,  # 時計のズレを30秒まで許容
            )
            
            # 追加検証:iat(発行時刻)が未来じゃないか
            if payload.get("iat", 0) > time.time() + 30:
                raise InvalidTokenError("Token issued in the future")
            
            return payload
        
        except InvalidTokenError as e:
            # ログは出すが、詳細なエラーはクライアントに返さない
            logger.warning(f"JWT validation failed: {type(e).__name__}")
            return None

leeway の設定はチームで揉めたポイントで、「30秒も緩めるの?」という意見もあった。ただマイクロサービス間で複数サーバーが絡む場合、時計の微妙なズレで正当なリクエストが弾かれることがある。30秒は現実的な許容範囲として落ち着いたけど、これはシステム構成によるので一律の正解はないかもしれない。

もう一個大事なのが JTI(JWT ID)による無効化。JWTの「ステートレス」という特徴は認証サーバーの負荷を下げる一方で、「ログアウトしたはずのトークンが使われ続ける」問題を引き起こす。

import redis

class TokenBlocklist:
    def __init__(self, redis_client: redis.Redis):
        self.redis = redis_client
    
    def revoke(self, jti: str, exp: int) -> None:
        """トークンを無効化(有効期限まで保持)"""
        ttl = exp - int(time.time())
        if ttl > 0:
            self.redis.setex(f"blocklist:{jti}", ttl, "1")
    
    def is_revoked(self, jti: str) -> bool:
        return self.redis.exists(f"blocklist:{jti}") == 1

これ、リフレッシュトークンのローテーション時に特に重要だ。古いリフレッシュトークンが盗まれて使われた場合、すでにローテーション済みのトークンが来たら即座にアカウント全体のセッションを無効化するのがベストプラクティスになってきた。


リフレッシュトークン設計で見落としがちな落とし穴

リフレッシュトークン周りは、実装が甘いまま本番に出ているサービスが多い印象がある。皆さんのチームはリフレッシュトークンの ローテーション(Refresh Token Rotation) ちゃんとやってますか?

ローテーションとは、リフレッシュトークンを使うたびに新しいリフレッシュトークンを発行し、古いものを無効化する仕組みだ。これがないと、リフレッシュトークンが漏洩した際に攻撃者が長期間アクセスし続けられる。数字で見るとその差は歴然で、ローテーションなしで30日有効のリフレッシュトークンが盗まれたら、単純計算で30日間ずっと悪用され続けることになる。

xychart-beta
    title "トークン盗難後の被害継続時間(分)"
    x-axis ["ローテーションなし", "ローテーションあり", "ローテーション+検知"]
    y-axis "被害継続時間(分)" 0 --> 44000
    bar [43200, 15, 0.5]

対してローテーションありなら、正規ユーザーが次回ログイン時に「古いトークンが既に使われている」を検知できる。この差は相当大きい。

実際の実装で気をつけるのが レプリケーション遅延 の問題だ。分散環境で複数のリクエストが同時に来た場合、古いリフレッシュトークンでの並列リクエストが一瞬だけ成功してしまうケースがある。これを防ぐにはRedisのLuaスクリプトでアトミックに処理するのが確実で、うちでも一度この問題を踏んでから切り替えた。

-- Redis Luaスクリプト:アトミックなトークンローテーション
local token_key = KEYS[1]
local new_token = ARGV[1]
local new_expiry = ARGV[2]

-- 現在のトークンが存在するか確認
local exists = redis.call('EXISTS', token_key)
if exists == 0 then
    return {err = 'TOKEN_NOT_FOUND'}
end

-- 古いトークンを削除して新しいトークンを設定(アトミック)
redis.call('DEL', token_key)
redis.call('SETEX', 'rt:' .. new_token, new_expiry, 'valid')

return {ok = new_token}

セキュリティ運用の観点では、SOC2対応2026年版でも認証ログの取り方について触れているので参考にしてほしい。トークン発行・失効・異常検知のログ設計は後から追加しようとすると本当に辛い。これは経験者として強くお勧めしておく。


2026年のOAuth脅威トレンドと対策

2026年に入って増えているのが OAuth consent phishing(同意フィッシング)DPoP(Demonstrating Proof of Possession) の未対応問題だ。

正規のOAuth認可フローを悪用して、ユーザーに「悪意のあるサードパーティアプリ」への権限付与を誘導する攻撃だ。メールやSlackで「このアプリを認証してください」と送りつけ、クリックするとGoogleやMicrosoftの本物の同意画面が出てくる。見た目が本物なので気づきにくい。技術的に難しい攻撃でもないので、件数が増えているのも納得感がある。

対策としては以下の3点が現実的だ。

  • 認可リクエストのクライアントID検証を強化する
  • 付与されるスコープの最小権限化(offline_access を必要な時だけ要求)
  • 組織管理者によるサードパーティアプリの事前承認制度を設ける

DPoP の採用検討

DPoP(RFC 9449)は2025年後半から主要な認可サーバーでサポートが広がった。従来のBearerトークンは盗まれると第三者がそのまま使えるが、DPoPはトークンを使う際に秘密鍵で署名したプルーフを添付することで、「トークンを持っているだけでは不十分」にする仕組みだ。

// DPoP プルーフ生成例
async function generateDPoPProof(
  privateKey: CryptoKey,
  httpMethod: string,
  httpUri: string,
  accessToken?: string
): Promise<string> {
  const header = {
    typ: 'dpop+jwt',
    alg: 'ES256',
    jwk: await exportPublicKey(privateKey),
  };

  const payload: Record<string, unknown> = {
    jti: crypto.randomUUID(),
    htm: httpMethod.toUpperCase(),
    htu: httpUri,
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 60, // 60秒有効
  };

  // アクセストークンのハッシュをバインド
  if (accessToken) {
    const tokenHash = await crypto.subtle.digest(
      'SHA-256',
      new TextEncoder().encode(accessToken)
    );
    payload.ath = btoa(String.fromCharCode(...new Uint8Array(tokenHash)))
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=/g, '');
  }

  return signJWT(header, payload, privateKey);
}

正直、まだDPoPを本番導入しているサービスは少数派で、うちのチームでも「次のメジャーリリースで対応」と先送りしている部分がある。ただ金融系・ヘルスケア系のAPIを扱うならもう必須に近いと思っていて、先送りし続けると後で痛い目を見そうだなとは感じている。

セキュリティヘッダーとCORS設定

認証エンドポイントのCORS設定ミスは、地味だけどまだ多い。

# 認可サーバーのNginx設定例
location /oauth/token {
    # CORSはallowlistで管理
    set $cors_origin "";
    if ($http_origin ~* "^https://(app|admin)\.example\.com$") {
        set $cors_origin $http_origin;
    }
    
    add_header Access-Control-Allow-Origin $cors_origin always;
    add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
    add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
    
    # セキュリティヘッダー
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "no-referrer" always;
    # トークンエンドポイントはキャッシュ禁止
    add_header Cache-Control "no-store" always;
    add_header Pragma "no-cache" always;
    
    if ($request_method = OPTIONS) {
        return 204;
    }
    
    proxy_pass http://auth_backend;
}

Access-Control-Allow-Origin: * にしているのを見かけることがあるが、認証エンドポイントにこれは絶対NG。ワイルドカードはリソースAPIでもできる限り避けるべきで、「とりあえず動かすためにワイルドカード」が後から大きな穴になるパターンをいくつか見てきた。


実運用で効いたモニタリングとアラート設計

認証周りのインシデントは、大抵「気づくのが遅い」のが問題だ。攻撃が始まってから気づくまでのMTTD(平均検知時間)を下げるために、以下のメトリクスを常時監視するようにした。個人的な体感では、これを入れる前と後でインシデント対応のストレスがかなり違う。

xychart-beta
    title "認証監視メトリクス優先度スコア(実運用評価)"
    x-axis ["ログイン失敗率", "トークン発行数異常", "異常IP分布", "リフレッシュ失敗", "スコープ変更検知"]
    y-axis "重要度スコア" 0 --> 100
    bar [95, 88, 82, 75, 60]

特に「リフレッシュトークンの失敗率急増」は、ローテーション検知(同一トークンが二重使用された兆候)のシグナルになる。これをDatadogかCloudWatch Anomaly Detectionで引っかけるようにしてから、不審なアクティビティの検知が格段に早くなった。

インシデント対応の体制についてはインシデント対応の最新ベストプラクティス2026も参考になる。認証基盤の障害は全サービスに波及するので、エスカレーション経路を事前に整理しておくのは本当に重要だ。

# 認証異常検知のCloudWatchメトリクス送信例
import boto3
from datetime import datetime

cloudwatch = boto3.client('cloudwatch')

def record_auth_metric(metric_name: str, value: float, unit: str = 'Count') -> None:
    cloudwatch.put_metric_data(
        Namespace='Auth/Security',
        MetricData=[
            {
                'MetricName': metric_name,
                'Value': value,
                'Unit': unit,
                'Timestamp': datetime.utcnow(),
                'Dimensions': [
                    {'Name': 'Environment', 'Value': 'production'},
                ]
            }
        ]
    )

# ログイン失敗をカウント
def handle_login_failure(user_id: str, ip_address: str, reason: str) -> None:
    record_auth_metric('LoginFailure', 1)
    
    # IP別の失敗回数をRedisで管理
    failure_count = redis_client.incr(f"login_fail:{ip_address}")
    redis_client.expire(f"login_fail:{ip_address}", 3600)  # 1時間ウィンドウ
    
    # 閾値超えでアラート
    if failure_count >= 100:
        record_auth_metric('SuspiciousIPDetected', 1)
        # ここでSlack通知やIP一時ブロックを実装
        trigger_security_alert(ip_address, failure_count)

Rate Limitingとの組み合わせも重要で、認証エンドポイントは特に厳しめに設定している。API Rate Limiting 2026の実装戦略で詳しく書いているので、認証エンドポイントの保護と合わせて読んでほしい。


まとめ

4年間OAuth・JWTを本番運用してきて、ようやく「何が本当に重要か」が整理できてきた気がする。要点をまとめるとこうなる。

  • OAuth 2.1 + PKCE は2026年の新規実装では必須。Implicit Flowは今すぐ廃止計画を立てること。stateパラメータの検証も忘れずに
  • JWTはalgを必ずサーバー側で強制指定。ライブラリ任せにするとalg:none攻撃のリスクが残る。クレームはexp・iss・audを全部検証
  • リフレッシュトークンのローテーションとJTIによる無効化は実装必須。RedisのLuaスクリプトでアトミックに処理することで並列リクエスト問題を防げる
  • DPoPは高セキュリティ要件の場合に検討。まだ普及途上だが金融・ヘルスケア系は対応を進めておくべき
  • 認証異常のモニタリングはDay 1から。ログイン失敗率・リフレッシュ失敗・異常IPを常時監視することで、インシデントの検知時間を大幅に短縮できる

次のアクション:

  1. 現在のOAuth実装でPKCEとstateパラメータが正しく実装されているか確認
  2. JWT検証コードでalg強制指定と全クレーム検証ができているかレビュー
  3. リフレッシュトークンのローテーション有無を確認し、未実装なら優先度を上げて実装

認証基盤は「動いてるから問題ない」で放置しがちな部分だけど、一度穴が開いたときの影響が全サービスに波及する。定期的なレビューを習慣にしてほしい。皆さんのチームで面白い認証設計のパターンがあれば、ぜひコメントで教えてください。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事