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
endINFO
リフレッシュトークンのローテーションとファミリー管理は、トークン盗難を検出するための重要な仕組みです。盗まれたリフレッシュトークンが使用されると、正規ユーザーの次の使用試行でリプレイ検出が発動し、ファミリー全体が無効化されます。
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
endRS256(非対称鍵)の使用
複数のサービスがトークンを検証する必要がある場合、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
}
endINFO
リフレッシュトークンをHttpOnly Cookieに保存することで、JavaScriptからアクセスできなくなり、XSS攻撃によるトークン盗難を防げます。SameSite=Strictを設定することでCSRF攻撃も防げます。
チェックリスト
- JWT署名検証は必ず有効にしている(
trueで検証) - 許可するアルゴリズムを明示的に指定している(
alg: noneを禁止) - 複数のアルゴリズムを許可していない(混在攻撃を防ぐ)
- アクセストークンの有効期限は短い(最大1時間)
-
exp,iat,iss,audクレームを検証している - リフレッシュトークンのローテーションが実装されている
- リプレイ攻撃検出のためファミリー管理を実装している
- ログアウト時にJWTをブラックリストに登録している
- JWTに機密情報(パスワード等)を含めていない
- リフレッシュトークンはHttpOnly Cookieで管理している