mybook

実例: 認証方式の選定 — JWT vs Session

モバイルアプリ追加の波紋

PostgreSQL移行プロジェクトが落ち着いた頃、プロダクトマネージャーのアオイが新しい要件を持ってきた。

「来Q、モバイルアプリをリリースしたい。iOS と Android。今のWebと同じAPIを使えればいいんだけど」

シンジはすぐにアーキテクチャ上の問題を把握した。

「現在の認証、CookieベースのSessionだよね。モバイルアプリからどうアクセスするか考えないといけない」

マイが頷いた。「そうか。Cookieってブラウザの仕組みだから、モバイルアプリから扱うのが難しい。JSONを返すAPIにしたほうがいい」

「認証方式を変えるなら今が機会かもしれない」とリョウが言った。「でも、どう変える? JWTとか?」

「まさにそこを議論したい」とシンジ。「そしてADRに記録する」

現在の認証アーキテクチャ

現状のRailsアプリはDevise + CookieセッションによるWebアプリ向けの認証だった。

# Gemfile
gem 'devise'
gem 'devise-jwt'  # まだ入れていない
 
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :authenticate_user!
 
  private
 
  def current_user
    @current_user ||= User.find_by(id: session[:user_id])
  end
end
Loading diagram...

「これだとモバイルアプリはどうなる?」とコウスケが聞いた。

「iOSやAndroidはHTTPクライアントでCookieを手動管理する必要がある。できないことはないけど、面倒くさいし、SPAやモバイルアプリには向いていない」

「実際に問題が起きたことある?」とユウキ。

「iOSのSwiftクライアントでCookie管理を実装したことあるけど、リダイレクト時のCookie引き継ぎが面倒だった。セッション管理の複雑さがフロント側に移る」

JWT vs Session の比較調査

チームは3日間かけて両方式を調査した。

Sessionベース認証の仕組み

セッションベース認証では、ログイン時にサーバー側でセッションデータを作成し、セッションIDをCookieで返す。

# ログイン処理(セッション方式)
class SessionsController < ApplicationController
  skip_before_action :authenticate_user!
 
  def create
    user = User.find_by(email: params[:email])
    if user&.authenticate(params[:password])
      session[:user_id] = user.id
      session[:logged_in_at] = Time.current.to_i
      render json: {
        message: "ログイン成功",
        user: { id: user.id, email: user.email }
      }
    else
      render json: { error: "メールアドレスまたはパスワードが間違っています" },
             status: :unauthorized
    end
  end
 
  def destroy
    session.delete(:user_id)
    render json: { message: "ログアウト成功" }
  end
end
 
# 認証チェック
def authenticate_user!
  user_id = session[:user_id]
  unless user_id
    render json: { error: "認証が必要です" }, status: :unauthorized
    return
  end
 
  @current_user = User.find_by(id: user_id)
  unless @current_user
    session.delete(:user_id)
    render json: { error: "ユーザーが見つかりません" }, status: :unauthorized
  end
end

セッション方式のメリット:

  • サーバー側でセッションを削除すれば即座にログアウトできる
  • セッション情報(ロール等)をサーバー側で管理するので変更が即座に反映
  • CSRF対策が Rails の protect_from_forgery で統合して対応できる

セッション方式のデメリット:

  • スケールアウト時にセッションストアの共有が必要(全台が同じRedisを見る必要)
  • モバイルアプリとの相性が悪い(Cookieの手動管理)
  • マイクロサービス化した場合に各サービスでセッション検証が必要

JWTとは何か

JWT(JSON Web Token)は、認証情報をサーバーではなくトークン自体に格納する方式だ。

# JWTの構造(3つのBase64エンコードされた部分をドットで区切る)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9    # Header: アルゴリズム情報
.
eyJ1c2VyX2lkIjoxMjMsInJvbGUiOiJ1c2VyIiwiZXhwIjoxNzA5MjU0NDAwfQ  # Payload: データ
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c   # Signature: 検証用署名

Payloadをデコードすると:

{
  "user_id": 123,
  "email": "shinji@example.com",
  "role": "user",
  "exp": 1709254400,
  "iat": 1709250800
}

サーバーはこのトークンをDBに保存しない。署名を検証するだけで認証できる。

# Gemfile
gem 'jwt'
 
# lib/jwt_service.rb
class JwtService
  ALGORITHM = 'HS256'.freeze
 
  def self.encode(payload, expires_in: 24.hours)
    payload = payload.merge(
      exp: expires_in.from_now.to_i,
      iat: Time.current.to_i
    )
    JWT.encode(payload, secret_key, ALGORITHM)
  end
 
  def self.decode(token)
    decoded = JWT.decode(
      token,
      secret_key,
      true,
      { algorithm: ALGORITHM }
    )
    decoded.first.with_indifferent_access
  rescue JWT::ExpiredSignature
    raise AuthError, "トークンの有効期限が切れています"
  rescue JWT::DecodeError => e
    raise AuthError, "無効なトークンです: #{e.message}"
  end
 
  def self.secret_key
    Rails.application.credentials.jwt_secret_key!
  end
  private_class_method :secret_key
end

JWTのメリット:

  • ステートレス: サーバー側にセッションストアが不要
  • スケールアウトが容易: どのサーバーに振り分けられても認証できる
  • モバイルアプリとの相性が良い: HTTPヘッダーで渡せる
  • マイクロサービスで各サービスが独立して検証できる

セキュリティの深堀り

「JWTって便利そうだけど、セキュリティリスクはないの?」とマイが聞いた。

シンジは「いくつか有名な問題がある」と言って説明を始めた。

JWTの問題1: トークンの即時無効化ができない

Loading diagram...

セッションなら session.delete(:user_id) で即座に無効化できる。しかしJWTはサーバーに状態を持たないため、有効期限が来るまで無効化できない。

有効期限を1時間にしても、その1時間は不正アクセスが続く可能性がある。

「パスワードが漏洩してパスワードリセットしても、JWTが1時間生きてたら意味がない」とリョウが指摘した。

JWTの問題2: alg none攻撃

古いJWTライブラリには、alg: "none" に変えることで署名検証をバイパスできる脆弱性があった。

# 悪い例: algを指定しない(古いライブラリで問題になる)
decoded = JWT.decode(token, secret)
 
# 良い例: algを明示的に指定してnone攻撃を防ぐ
decoded = JWT.decode(token, secret, true, algorithms: ['HS256'])

最近のgemは対策済みだが、設定ミスで同様の問題が起きうる。

JWTの問題3: トークンの格納場所

クライアントサイドでどこにJWTを保存するかが問題になる。

格納場所XSSリスクCSRFリスク推奨度
localStorage高(JSから読める)なし非推奨
sessionStorage高(タブ内のみだが漏洩)なし非推奨
Cookie(httpOnly)なし(JSから読めない)あり条件付き推奨
メモリ(JS変数)タブ内のみなしSPAなら推奨

「localStorageに保存するJWT実装をよく見るけど、XSSがあったら全トークンが漏れる」とシンジは言った。「localStorageは絶対に使わない」

WARNING

JWTをlocalStorageに保存することは、XSSに対して完全にノーガードになる。XSS脆弱性があれば、document.localStorage.getItem('token') で簡単に全ユーザーのトークンが盗める。Railsアプリでは、JWTはhttpOnly Cookieかメモリに保存すること。

解決策: Refresh Token付きJWTハイブリッド方式

シンジはリサーチの結果、単純なJWTでも純粋なSessionでもない、中間の解決策を提案した。

**アクセストークン(JWT)+ リフレッシュトークン(DB管理)**のハイブリッド方式だ。

Loading diagram...

「これで何が解決する?」とコウスケが聞いた。

「アクセストークンは15分しか有効じゃないから、漏洩しても影響が小さい。そしてリフレッシュトークンはDBで管理するから、強制ログアウトが即座にできる」

実装

# db/migrate/20240310_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,      null: false
      t.boolean :revoked,    null: false, default: false
      t.datetime :expires_at, null: false
      t.string  :device_info  # どのデバイスのトークンか(任意)
      t.string  :ip_address   # ログイン時のIPアドレス(監査用)
      t.timestamps
    end
 
    add_index :refresh_tokens, :token, unique: true
    add_index :refresh_tokens, [:user_id, :revoked]
  end
end
# app/models/refresh_token.rb
class RefreshToken < ApplicationRecord
  belongs_to :user
 
  EXPIRES_IN = 30.days
 
  before_create :generate_token
 
  scope :active, -> { where(revoked: false).where("expires_at > ?", Time.current) }
 
  def self.issue_for(user, device_info: nil, ip_address: nil)
    user.refresh_tokens.create!(
      expires_at: EXPIRES_IN.from_now,
      device_info: device_info,
      ip_address: ip_address
    )
  end
 
  def expired?
    expires_at < Time.current
  end
 
  def active?
    !revoked? && !expired?
  end
 
  def revoke!
    update!(revoked: true)
  end
 
  private
 
  def generate_token
    self.token = SecureRandom.urlsafe_base64(32)
  end
end
# app/controllers/api/v1/auth_controller.rb
class Api::V1::AuthController < ApplicationController
  skip_before_action :authenticate_user!, only: [:login, :refresh]
 
  def login
    user = User.find_by(email: params[:email].downcase)
 
    unless user&.authenticate(params[:password])
      # セキュリティ: メールアドレスの存在有無を漏らさないために
      # 「ユーザーが存在しない」と「パスワードが間違い」を同じエラーにする
      return render json: { error: "メールアドレスまたはパスワードが間違っています" },
                    status: :unauthorized
    end
 
    access_token = JwtService.encode(
      { user_id: user.id, role: user.role },
      expires_in: 15.minutes
    )
 
    refresh_token = RefreshToken.issue_for(
      user,
      device_info: request.user_agent,
      ip_address: request.remote_ip
    )
 
    render json: {
      access_token: access_token,
      refresh_token: refresh_token.token,
      expires_in: 15 * 60  # 秒
    }
  end
 
  def refresh
    rt = RefreshToken.find_by(token: params[:refresh_token])
 
    if rt.nil? || !rt.active?
      return render json: { error: "リフレッシュトークンが無効です" },
                    status: :unauthorized
    end
 
    # 古いトークンを無効化してローテーション
    rt.revoke!
 
    new_access_token = JwtService.encode(
      { user_id: rt.user_id, role: rt.user.role },
      expires_in: 15.minutes
    )
 
    new_refresh_token = RefreshToken.issue_for(
      rt.user,
      device_info: request.user_agent,
      ip_address: request.remote_ip
    )
 
    render json: {
      access_token: new_access_token,
      refresh_token: new_refresh_token.token,
      expires_in: 15 * 60
    }
  end
 
  def logout
    rt = RefreshToken.find_by(token: params[:refresh_token])
    rt&.revoke!
 
    render json: { message: "ログアウトしました" }
  end
 
  def logout_all_devices
    current_user.refresh_tokens.active.find_each(&:revoke!)
    render json: { message: "すべてのデバイスからログアウトしました" }
  end
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :authenticate_user!
 
  private
 
  def authenticate_user!
    token = extract_token_from_header
    return render_unauthorized("認証トークンがありません") unless token
 
    begin
      payload = JwtService.decode(token)
      @current_user = User.find(payload[:user_id])
    rescue AuthError => e
      render_unauthorized(e.message)
    rescue ActiveRecord::RecordNotFound
      render_unauthorized("ユーザーが見つかりません")
    end
  end
 
  def extract_token_from_header
    header = request.headers["Authorization"]
    return nil unless header&.start_with?("Bearer ")
 
    header.split(" ").last
  end
 
  def render_unauthorized(message)
    render json: { error: message }, status: :unauthorized
  end
end

ADRを書く

# ADR-012: 認証方式をSession + CookieからJWT + Refresh Tokenのハイブリッドに移行する
 
## Status
Accepted
 
## Context
2024年3月、モバイルアプリ(iOS/Android)の開発が決定した。
リリース予定: 2024年Q3(約4ヶ月後)
 
現在の認証方式の課題:
- CookieベースのセッションはモバイルHTTPクライアントとの相性が悪い
- React NativeやFlutterでCookieを管理する実装が複雑
- スケールアウト時のセッション共有(現在はRedisで解決済みだが複雑さが残る)
 
セキュリティ要件(プロダクト要件から):
- パスワード変更時に全デバイスからログアウト可能にする
- 不正ログイン検知時に特定デバイスのアクセスを即座に無効化
- この要件があるため、Pure JWTでは対応不可
 
現在のインフラ:
- Rails 7.1 + Devise(セッション認証)
- Redis(セッションストア)
- ECS Fargate(アプリサーバー x3台)
 
検討した方式:
1. 現状のSession認証を継続(モバイルでCookie管理)
2. JWTのみ(Pure JWT, ステートレス)
3. JWT + Refresh Token(ハイブリッド)
4. OAuth 2.0のDevice Flowを使う
5. Cognitoなど外部認証サービスを使う
 
## Decision
JWT(アクセストークン)+ Refresh Token(DBで管理)のハイブリッド方式を採用する。
 
- アクセストークン: JWT、有効期限15分、`Authorization: Bearer <token>` ヘッダーで送受信
- リフレッシュトークン: DBに保存、有効期限30日、ローテーション方式
 
Pure JWTを採用しない理由:
- アクセストークンの即時無効化ができない(セキュリティ要件を満たせない)
- パスワード変更後も15分は古いトークンが有効(これは許容できないレベルのリスク)
 
Session継続を採用しない理由:
- モバイルアプリとの互換性問題が解消されない
- ECSのオートスケール時にRedisセッションストアへの依存が増える
 
OAuth 2.0 Device Flowを採用しない理由:
- 自社プロダクトの認証に外部IdPを挟む必要性がない
- 実装コストが高い割に現時点での要件に対してオーバースペック
 
Cognitoを採用しない理由:
- 現時点のMAU(約30,000)ではコスト優位性が低い(無料枠を超える場合の料金増)
- 認証ロジックのカスタマイズがCognitoの制約に縛られる
- Deviseから移行する工数が高い(ADR再評価のトリガーに明記)
 
## Consequences
良い影響:
- モバイルアプリからのAPI認証がHTTPヘッダーのみで完結
- リフレッシュトークンのDB管理により、即時の強制ログアウトが可能
- アクセストークンが15分で失効するため、漏洩リスクが低い
- Webブラウザでも同じAPIを使用できる(SPAへの移行も容易)
 
悪い影響・リスク:
- refresh_tokensテーブルの管理が必要(期限切れトークンの定期削除が必要)
- セッション方式よりも実装が複雑になる
- アクセストークンの15分は短すぎる場合、UXに影響する可能性がある
  (リフレッシュが必要なタイミングでのエラー処理を適切に実装する必要)
- モバイルアプリがリフレッシュを実装する責任を持つ
 
セキュリティ上の注意事項(実装者必読):
- アクセストークンはメモリ(JS変数)またはhttpOnly Cookieに保存
  **localStorageは絶対に使用禁止(XSSで全トークン漏洩)**
- リフレッシュトークンは iOS Keychain / Android Keystore に保存
- アルゴリズムはHS256を明示的に指定(none攻撃対策)
- トークンローテーション: リフレッシュ時に古いリフレッシュトークンを必ず無効化
- HTTPS必須(開発環境以外でHTTPでの通信禁止)
 
再評価のトリガー:
- ユーザー数が100万MAUを超えてDB管理のリフレッシュトークンがボトルネックになった場合
  → Redis等のインメモリストアへの移行を検討
- SSO(シングルサインオン)要件が発生した場合 → Cognito/Auth0への移行を検討
- セキュリティインシデントが発生してトークン設計の見直しが必要になった場合

セキュリティレビューの重要性

マイがADRを読んで指摘した。「これ、ConsequencesにlocalStorageは絶対に使用禁止って書いてあるけど、フロントチームとモバイルチームに伝わる?」

「それがADRを書く理由の一つ」とシンジは答えた。「決定と同時に、その理由もドキュメントに残る。フロントチームがこのADRを読めば、なぜlocalStorageを使ってはいけないか分かる」

「でも全員がADRを読むとは限らないよね」

「そこはコードレビューで強制する。フロントのPRでlocalStorageを使ったJWT格納が出てきたら、このADR-012を参照してコメントを入れる」

実際に2週間後、フロントエンドの実装PRでこういうコードが出てきた:

// フロントエンドのPRで発見されたコード
const handleLogin = async (email, password) => {
  const response = await api.post('/api/v1/auth/login', { email, password });
  // NG: localStorageに保存しようとしている
  localStorage.setItem('access_token', response.data.access_token);
};

シンジはコメントを入れた:

ADR-012(docs/adr/012-jwt-refresh-token-auth.md)のConsequencesセクション参照。
localStorageへのアクセストークン保存は、XSSに対してノーガードになるため禁止。
メモリ(変数)に保存し、リロード時はリフレッシュトークンで再取得する実装に変更をお願いします。

PRの作成者はADRを読んで理解し、実装を修正した。

INFO

ADRの「セキュリティ上の注意事項」はコードレビューの判断基準になる。「なぜダメか」がADRに書いてあれば、レビュアーはその都度説明する必要がなく、ADRを参照するだけで良い。これがADRによるコードレビューコストの削減だ。

トークンのライフサイクル管理

Consequencesに書いた「期限切れトークンの定期削除」を自動化するRakeタスクも実装した。

# lib/tasks/cleanup.rake
namespace :cleanup do
  desc "期限切れ・無効化されたリフレッシュトークンを削除"
  task refresh_tokens: :environment do
    count = RefreshToken.where(
      "expires_at < ? OR revoked = ?",
      30.days.ago,
      true
    ).delete_all
 
    Rails.logger.info("Cleaned up #{count} refresh tokens")
    puts "削除完了: #{count}件"
  end
end
 
# ECSのScheduled TaskまたはSidekiqのCronで毎日実行
# 0 2 * * * bundle exec rails cleanup:refresh_tokens
# app/jobs/cleanup_refresh_tokens_job.rb
class CleanupRefreshTokensJob < ApplicationJob
  queue_as :maintenance
 
  def perform
    # 期限切れ(30日以上前)または明示的に無効化されたトークンを削除
    deleted_count = RefreshToken.where(
      "expires_at < ? OR revoked = ?",
      30.days.ago,
      true
    ).delete_all
 
    Rails.logger.info(
      "CleanupRefreshTokensJob: deleted #{deleted_count} tokens at #{Time.current}"
    )
  end
end

テスト戦略

# spec/requests/api/v1/auth_spec.rb
RSpec.describe "Api::V1::Auth", type: :request do
  let(:user) { create(:user, password: "secure_password123") }
 
  describe "POST /api/v1/auth/login" do
    context "正しい認証情報の場合" do
      it "アクセストークンとリフレッシュトークンを返す" do
        post "/api/v1/auth/login", params: {
          email: user.email,
          password: "secure_password123"
        }
 
        expect(response).to have_http_status(:ok)
        json = JSON.parse(response.body)
        expect(json["access_token"]).to be_present
        expect(json["refresh_token"]).to be_present
        expect(json["expires_in"]).to eq(900)  # 15分 = 900秒
      end
    end
 
    context "間違ったパスワードの場合" do
      it "401を返す(メールアドレスの存在を漏らさない)" do
        post "/api/v1/auth/login", params: {
          email: user.email,
          password: "wrong_password"
        }
 
        expect(response).to have_http_status(:unauthorized)
        json = JSON.parse(response.body)
        # セキュリティ: メールアドレスの存在有無を漏らさない
        expect(json["error"]).to eq("メールアドレスまたはパスワードが間違っています")
      end
    end
  end
 
  describe "POST /api/v1/auth/refresh" do
    context "有効なリフレッシュトークンの場合" do
      let!(:refresh_token) { RefreshToken.issue_for(user) }
 
      it "新しいアクセストークンとリフレッシュトークンを返す" do
        post "/api/v1/auth/refresh", params: { refresh_token: refresh_token.token }
 
        expect(response).to have_http_status(:ok)
        json = JSON.parse(response.body)
        expect(json["access_token"]).to be_present
        expect(json["refresh_token"]).not_to eq(refresh_token.token)  # ローテーション
      end
 
      it "古いリフレッシュトークンが無効化される(ローテーション)" do
        post "/api/v1/auth/refresh", params: { refresh_token: refresh_token.token }
 
        expect(refresh_token.reload).to be_revoked
      end
    end
 
    context "無効なリフレッシュトークンの場合" do
      it "401を返す" do
        post "/api/v1/auth/refresh", params: { refresh_token: "invalid_token" }
 
        expect(response).to have_http_status(:unauthorized)
      end
    end
  end
 
  describe "DELETE /api/v1/auth/logout_all" do
    let(:access_token) do
      JwtService.encode({ user_id: user.id }, expires_in: 15.minutes)
    end
 
    it "全デバイスのリフレッシュトークンが無効化される" do
      3.times { RefreshToken.issue_for(user) }
      expect(user.refresh_tokens.active.count).to eq(3)
 
      delete "/api/v1/auth/logout_all",
             headers: { "Authorization" => "Bearer #{access_token}" }
 
      expect(response).to have_http_status(:ok)
      expect(user.refresh_tokens.active.count).to eq(0)
    end
  end
end

チームは翌日からモバイルアプリ対応APIの実装を始めた。ADR-012がチームの共通認識となり、フロント・バックエンド・インフラ全員が同じ方針で動けた。

「ADRがあってよかった」とフロントのリードが言った。「実装方針のブレがゼロだった」


次の章では、インフラ構成の選定を例に、ECSとEKSのトレードオフをADRで記録する方法を学ぶ。「今必要なものと将来必要なもの」のバランスをどう記録するかがテーマだ。