実例: 認証方式の選定 — 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「これだとモバイルアプリはどうなる?」とコウスケが聞いた。
「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
endJWTのメリット:
- ステートレス: サーバー側にセッションストアが不要
- スケールアウトが容易: どのサーバーに振り分けられても認証できる
- モバイルアプリとの相性が良い: HTTPヘッダーで渡せる
- マイクロサービスで各サービスが独立して検証できる
セキュリティの深堀り
「JWTって便利そうだけど、セキュリティリスクはないの?」とマイが聞いた。
シンジは「いくつか有名な問題がある」と言って説明を始めた。
JWTの問題1: トークンの即時無効化ができない
セッションなら 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管理)**のハイブリッド方式だ。
「これで何が解決する?」とコウスケが聞いた。
「アクセストークンは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
endADRを書く
# 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で記録する方法を学ぶ。「今必要なものと将来必要なもの」のバランスをどう記録するかがテーマだ。