mybook

JWT の正しい使い方 — トークンの落とし穴

「JWT使ってるけど、アルゴリズムをnoneにしたら検証をスキップできるぞ」

セキュリティ勉強会でリョウはそのスライドを見て、震えた。自分のコードを確認すると、まさにそのパターンが存在していた。

# 脆弱なコード(実際のリョウのコードベースにあったもの)
JWT.decode(token, nil, false)  # 第3引数falseで署名検証をスキップ!

falseを渡すことで、JWTの署名検証を完全に無効化していた。攻撃者は任意のペイロードを持つJWTを偽造できる状態だった。

JWTの構造を理解する

JWTは3つのパートから構成される。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlJ5byIsImlhdCI6MTUxNjIzOTAyMn0.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header:  { "alg": "HS256", "typ": "JWT" }
Payload: { "sub": "1", "name": "Ryo", "iat": 1516239022 }
Signature: HMACSHA256(base64(header) + "." + base64(payload), secret)

WARNING

JWTのペイロードはBase64エンコードされているだけで、暗号化されていません。誰でもデコードして内容を読めます。機密情報(パスワード、クレジットカード番号等)はJWTに含めてはいけません。

よくある脆弱性パターン

1. alg: none攻撃

# 攻撃者が作るトークン
# ヘッダーのalgをnoneに変更し、署名部分を空にする
header = Base64.urlsafe_encode64('{"alg":"none","typ":"JWT"}', padding: false)
payload = Base64.urlsafe_encode64('{"sub":"1","admin":true}', padding: false)
malicious_token = "#{header}.#{payload}."  # 署名なし
 
# 脆弱なサーバーはこれを有効なトークンと判断してしまう
JWT.decode(malicious_token, secret, false)  # falseで検証スキップ → 通ってしまう

2. アルゴリズム混在攻撃(RS256からHS256へ)

# RS256(RSA)で署名されることを期待しているサーバーに対して
# HS256(HMAC)で署名し、公開鍵を「秘密鍵」として使う攻撃
 
# 攻撃手順:
# 1. 公開鍵を取得(JWKSエンドポイントから)
# 2. 取得した公開鍵を「HMAC秘密鍵」として使用
# 3. HS256で署名したトークンをサーバーに送る
# 4. サーバーが公開鍵でHMAC検証すると、攻撃成功
 
# 脆弱なコード
JWT.decode(token, public_key, true, algorithms: ['RS256', 'HS256'])
# ↑ 複数のアルゴリズムを許可すると危険

3. 有効期限なしのトークン

# 脆弱なコード(有効期限を設定していない)
payload = { sub: user.id, role: 'admin' }
token = JWT.encode(payload, secret, 'HS256')
# このトークンは永遠に有効 = 漏洩したら永続的なリスク

安全なJWT実装

# lib/json_web_token.rb
class JsonWebToken
  ALGORITHM = 'HS256'
  # HS256かRS256のみ許可(混在禁止)
  ALLOWED_ALGORITHMS = [ALGORITHM].freeze
 
  class << self
    def encode(payload, exp: 1.hour.from_now)
      payload = payload.merge(
        exp: exp.to_i,
        iat: Time.current.to_i,
        jti: SecureRandom.uuid,  # JWT IDで一意性を保証
        iss: issuer,             # 発行者
        aud: audience            # 対象者
      )
      JWT.encode(payload, secret_key, ALGORITHM)
    end
 
    def decode(token)
      options = {
        algorithms: ALLOWED_ALGORITHMS,  # 単一アルゴリズムのみ
        verify_expiration: true,         # 有効期限を必ず検証
        verify_iat: true,                # 発行時刻を検証
        verify_iss: true,                # 発行者を検証
        verify_aud: true,                # 対象者を検証
        iss: issuer,
        aud: audience
      }
 
      decoded = JWT.decode(token, secret_key, true, options)
      HashWithIndifferentAccess.new(decoded.first)
    rescue JWT::ExpiredSignature
      raise TokenExpiredError, "Token has expired"
    rescue JWT::InvalidIssuerError
      raise TokenInvalidError, "Invalid token issuer"
    rescue JWT::InvalidAudError
      raise TokenInvalidError, "Invalid token audience"
    rescue JWT::DecodeError => e
      raise TokenInvalidError, "Invalid token: #{e.message}"
    end
 
    private
 
    def secret_key
      Rails.application.credentials.jwt_secret!
    end
 
    def issuer
      "stockflow-api"
    end
 
    def audience
      "stockflow-clients"
    end
  end
end
 
class TokenExpiredError < StandardError; end
class TokenInvalidError < StandardError; end

リフレッシュトークンの設計

アクセストークンは短命(1時間)に保ちつつ、ユーザーが頻繁にログインし直さなくて済むようにリフレッシュトークンを使う。

Loading diagram...
# db/migrate/20240115000003_create_refresh_tokens.rb
class CreateRefreshTokens < ActiveRecord::Migration[7.1]
  def change
    create_table :refresh_tokens do |t|
      t.references :user, null: false, foreign_key: true
      t.string :token_digest, null: false
      t.string :family, null: false  # トークンファミリーでリプレイ検出
      t.datetime :expires_at, null: false
      t.datetime :used_at
      t.boolean :revoked, default: false
      t.string :user_agent
      t.string :ip_address
      t.string :device_id
 
      t.timestamps
    end
 
    add_index :refresh_tokens, :token_digest, unique: true
    add_index :refresh_tokens, :family
    add_index :refresh_tokens, [:user_id, :revoked]
  end
end
# app/models/refresh_token.rb
class RefreshToken < ApplicationRecord
  belongs_to :user
 
  EXPIRES_IN = 30.days
 
  scope :valid, -> {
    where(revoked: false)
      .where('expires_at > ?', Time.current)
      .where(used_at: nil)
  }
 
  def self.issue(user, request_info = {})
    raw_token = SecureRandom.urlsafe_base64(64)
 
    token = create!(
      user: user,
      token_digest: digest(raw_token),
      family: SecureRandom.uuid,
      expires_at: EXPIRES_IN.from_now,
      user_agent: request_info[:user_agent],
      ip_address: request_info[:ip_address],
      device_id: request_info[:device_id]
    )
 
    { raw_token: raw_token, id: token.id, family: token.family }
  end
 
  def self.rotate(raw_token, request_info = {})
    current = find_by_raw_token(raw_token)
 
    unless current&.valid_for_use?
      # リプレイ検出:使用済みトークンが再使用された場合、ファミリー全体を無効化
      if current&.used_at.present?
        revoke_family!(current.family)
        raise TokenReplayError, "Refresh token reuse detected. All sessions invalidated."
      end
 
      raise TokenInvalidError, "Invalid or expired refresh token"
    end
 
    # 古いトークンを使用済みにマーク(削除はしない、監査証跡のため)
    current.update!(used_at: Time.current)
 
    # 新しいトークンを同じファミリーで発行
    new_raw = SecureRandom.urlsafe_base64(64)
    new_token = create!(
      user: current.user,
      token_digest: digest(new_raw),
      family: current.family,  # ファミリーを継続
      expires_at: EXPIRES_IN.from_now,
      user_agent: request_info[:user_agent],
      ip_address: request_info[:ip_address]
    )
 
    { raw_token: new_raw, user: current.user }
  end
 
  def valid_for_use?
    !revoked? && expires_at > Time.current && used_at.nil?
  end
 
  private
 
  def self.digest(token)
    Digest::SHA256.hexdigest(token)
  end
 
  def self.find_by_raw_token(raw_token)
    find_by(token_digest: digest(raw_token))
  end
 
  def self.revoke_family!(family)
    where(family: family).update_all(revoked: true)
  end
end

INFO

リフレッシュトークンのローテーションとファミリー管理は、トークン盗難を検出するための重要な仕組みです。盗まれたリフレッシュトークンが使用されると、正規ユーザーの次の使用試行でリプレイ検出が発動し、ファミリー全体が無効化されます。

JWTのブラックリスト

JWTは本質的にステートレスだが、ログアウト時などにトークンを無効化したい場合はブラックリストが必要だ。

# app/services/jwt_blacklist_service.rb
class JwtBlacklistService
  KEY_PREFIX = "jwt_blacklist:"
  DEFAULT_TTL = 24.hours  # アクセストークンの最大有効期限より長く
 
  def self.revoke(jti, exp)
    ttl = [exp - Time.current.to_i, DEFAULT_TTL.to_i].min
    return if ttl <= 0
 
    Rails.cache.write(
      "#{KEY_PREFIX}#{jti}",
      true,
      expires_in: ttl
    )
  end
 
  def self.revoked?(jti)
    Rails.cache.exist?("#{KEY_PREFIX}#{jti}")
  end
end
# config/initializers/redis.rb(キャッシュストアの設定)
Rails.application.config.cache_store = :redis_cache_store, {
  url: ENV['REDIS_URL'],
  namespace: "stockflow",
  expires_in: 1.day,
  error_handler: -> (method:, returning:, exception:) {
    Sentry.capture_exception(exception)
    Rails.logger.error("Redis cache error: #{exception.message}")
  }
}
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  before_action :authenticate_user!
 
  private
 
  def authenticate_user!
    token = extract_token
    return unauthorized! unless token
 
    payload = JsonWebToken.decode(token)
 
    # ブラックリストチェック
    if JwtBlacklistService.revoked?(payload[:jti])
      return unauthorized!("Token has been revoked")
    end
 
    @current_user = User.find(payload[:sub])
  rescue TokenExpiredError
    unauthorized!("Token has expired")
  rescue TokenInvalidError => e
    unauthorized!(e.message)
  end
 
  def extract_token
    request.headers['Authorization']&.split('Bearer ')&.last
  end
 
  def unauthorized!(message = "Unauthorized")
    render json: { error: message, code: "UNAUTHORIZED" }, status: :unauthorized
  end
end

RS256(非対称鍵)の使用

複数のサービスがトークンを検証する必要がある場合、RS256が適している。

# RS256の鍵ペア生成
# openssl genrsa -out private_key.pem 2048
# openssl rsa -in private_key.pem -pubout -out public_key.pem
 
# config/initializers/jwt_config.rb
module JwtConfig
  PRIVATE_KEY = OpenSSL::PKey::RSA.new(
    Rails.application.credentials.dig(:jwt, :private_key)
  )
  PUBLIC_KEY = PRIVATE_KEY.public_key
 
  # 公開鍵をJWKS形式で公開(他のサービスが検証できるように)
  def self.jwks
    {
      keys: [
        {
          kty: "RSA",
          use: "sig",
          alg: "RS256",
          kid: "stockflow-2024-01",
          n: Base64.urlsafe_encode64(PUBLIC_KEY.n.to_s(2), padding: false),
          e: Base64.urlsafe_encode64(PUBLIC_KEY.e.to_s(2), padding: false)
        }
      ]
    }
  end
end
# app/controllers/api/v1/jwks_controller.rb
module Api
  module V1
    class JwksController < ApplicationController
      skip_before_action :authenticate_user!
 
      def index
        render json: JwtConfig.jwks
      end
    end
  end
end

トークンのセキュアな格納

クライアント側のトークン保存も重要だ。

// ブラウザでのトークン管理(推奨パターン)
class TokenStorage {
  // アクセストークン:メモリに保存(XSS攻撃でCookieやlocalStorageより安全)
  #accessToken = null;
 
  setAccessToken(token) {
    this.#accessToken = token;
  }
 
  getAccessToken() {
    return this.#accessToken;
  }
 
  // リフレッシュトークン:HttpOnly Cookieで管理
  // サーバー側でSet-Cookie: refresh_token=xxx; HttpOnly; Secure; SameSite=Strict
}
# app/controllers/api/v1/sessions_controller.rb(Cookieでリフレッシュトークンを管理)
def create
  # 認証処理...
 
  access_token = JsonWebToken.encode({ sub: user.id })
  refresh_data = RefreshToken.issue(user, request_info)
 
  # リフレッシュトークンはHttpOnly Cookieに
  cookies[:refresh_token] = {
    value: refresh_data[:raw_token],
    httponly: true,
    secure: Rails.env.production?,
    same_site: :strict,
    expires: 30.days.from_now
  }
 
  # アクセストークンはレスポンスボディに
  render json: {
    access_token: access_token,
    token_type: "Bearer",
    expires_in: 3600
  }
end

INFO

リフレッシュトークンをHttpOnly Cookieに保存することで、JavaScriptからアクセスできなくなり、XSS攻撃によるトークン盗難を防げます。SameSite=Strictを設定することでCSRF攻撃も防げます。

チェックリスト

  • JWT署名検証は必ず有効にしている(trueで検証)
  • 許可するアルゴリズムを明示的に指定している(alg: noneを禁止)
  • 複数のアルゴリズムを許可していない(混在攻撃を防ぐ)
  • アクセストークンの有効期限は短い(最大1時間)
  • exp, iat, iss, aud クレームを検証している
  • リフレッシュトークンのローテーションが実装されている
  • リプレイ攻撃検出のためファミリー管理を実装している
  • ログアウト時にJWTをブラックリストに登録している
  • JWTに機密情報(パスワード等)を含めていない
  • リフレッシュトークンはHttpOnly Cookieで管理している