最小の認証システム自作(セッション vs JWT vs パスキー) —— なぜ認証の歴史は「ステートレス化」を経て「ハードウェア公開鍵」へ行き着いたのか?

2026-11-05 / シリーズ「攻防の物理」第6話 / CyberSecurity, 認証, JWT, WebAuthn, 暗号技術

はじめに:なぜ「ログイン」の仕組みはこれほど激変し続けるのか?

Web開発の歴史を振り返ると、認証(Authentication)ほど流行が移り変わり、エンジニアを議論の泥沼に巻き込んできた領域はありません。

  1. 2000年代〜:「CookieにランダムなセッションIDを入れて、サーバーのDBやメモリで照合する」のが絶対の王道でした。
  2. 2010年代〜:「マイクロサービスとSPAの時代だ!サーバーに状態を持たせないJWT(JSON Web Token)こそが未来だ」と大合唱が起きました。
  3. 2020年代後半(現代):「パスワードも共有秘密もやめよう。端末のハードウェアに秘密鍵を閉じ込める『パスキー(Passkeys / WebAuthn)』が最終解答だ」と世界標準が一気に舵を切りました。

なぜ、先人たちが苦心して作り上げた仕組みは次々と塗り替えられていったのでしょうか? 単なる流行り廃りではありません。そこには、「サーバーのスケール限界(ステートの壁)」、「分散システムにおける即時失効の矛盾」、そして**「人間を騙すフィッシング攻撃に対する物理的無力さ」**という、極めて具体的な物理的制約との戦いがありました。

3大認証アーキテクチャの内部力学を、最小コードとシーケンス図から徹底解剖します。


1. 古典セッションの物理 —— ステートフルな中央集権台帳

すべての基本である「Cookieベースのセッション管理」は、現実世界の**「クロークの荷物札(引き換え券)」**と全く同じ物理モデルです。

🌐 ブラウザ → サーバー: ID・パスワードを送信(POST /login) 🖥️ サーバー: パスワード照合 → ストアに "sess_abc123" = {user_id: 42} を記録 🍪 サーバー → ブラウザ: Set-Cookie: sid=sess_abc123(HttpOnly; Secure) 🌐 ブラウザ → サーバー: リクエスト + Cookie: sid=sess_abc123 🗄️ サーバー → ストア: 「sess_abc123 の持ち主は?」 → {user_id: 42} 🖥️ サーバー → ブラウザ: ユーザー42番用の画面を返却

なぜ優れていたのか?(強み)

セッションID(sess_abc123)そのものは、暗号論的疑似乱数生成器(CSPRNG)で作られた**「何の意味も持たないランダムな文字列(不透明なトークン:Opaque Token)」**です。 ユーザーのIDも権限情報も、すべてサーバー側のデータベース(セッションストア)にしか存在しません。

  • 即時失効の完全性:ユーザーが「ログアウト」を押すか、管理者が「強制BAN」した瞬間、サーバー側のストアから行を1行消去(DEL sess_abc123)するだけで、そのセッションは1ミリ秒の遅延もなく即座に無効化されます。

どこで限界にぶち当たったのか?(物理的ボトルネック)

サービスの規模が拡大し、Webサーバーを1台から数十台、数百台へと水平スケール(スケールアウト)させた瞬間に、「状態(State)」の物理的重量が牙を剥きます。

👤 ユーザー Cookie: sid=sess_abc123 ロードバランサ リクエストを振り分け Webサーバー A メモリは他と共有できない! Webサーバー B メモリは他と共有できない! Webサーバー C メモリは他と共有できない! 中央Redis 全リクエストが ここに殺到! ★単一障害点 サーバーを増やすほど、中央ストアへの問い合わせが集中し、遅延とコストが跳ね上がる
  1. メモリスケールの破綻:サーバーのオンメモリにセッションを置くと、ロードバランサが別サーバーにパケットを振り分けた瞬間に「未ログイン状態」になります(スティッキーセッションが必要になり負荷分散が歪む)。
  2. 中央ストアのI/Oボトルネック:全サーバーからRedis等のKVSへ問い合わせを集中させると、今度はそのKVSがシステムの単一障害点(SPOF)となり、通信遅延とコストが跳ね上がります。

2. JWTの物理 —— サーバーに記憶させない「署名付き身分証」

「毎回サーバー側のデータベースを見に行くから遅いんだ。ユーザーの情報そのものを暗号学的に保護してクライアントに持たせれば、サーバーは状態(State)を記憶する必要がないのではないか?」

この思想から生まれたのが JWT(JSON Web Token) です。 クロークの荷物札ではなく、**「公的機関の割印が押された住民票」**を手元に持たせる方式です。

JWTの解剖図:3つのパーツ

JWTは、ドット(.)で区切られた3つのBase64URL文字列で構成されています。

eyJhbGciOi... . eyJzdWIiOi... . 4f8b9c...
[  Header  ] . [  Payload ] . [ Signature ]
1. ヘッダー アルゴリズム: HMAC-SHA256 など 2. ペイロード クレーム: user_id: 42, exp など(平文) Base64URLエンコードしてドットで結合 HMAC-SHA256(結合文字列, サーバー秘密鍵) 3. デジタル署名 秘密鍵による改ざん検知の刻印
  • Header:どの暗号アルゴリズム(例:HS256)で署名されたか。
  • Payload:ユーザーID、有効期限(exp)、権限ロールなどの実データ(平文)。
  • Signature(署名):サーバーだけが知っている「秘密鍵」を使って、HeaderとPayloadをハッシュ化した改ざん防止の刻印。

サーバーはデータベースを一切見ません。 送られてきたトークンの Header と Payload を手元の秘密鍵で再計算し、「Signatureと完全一致するか」をCPUの算術演算だけで検証します。一致すれば、そこに書かれている user_id: 42 を無条件で信用します。

触って試す:JWTの中身と改ざん検知

🔏 JWTをのぞいて、改ざんしてみる

下がサンプルのJWTです。ヘッダー・ペイロード・署名の3部分に色分けしています。ペイロードはBase64URLでエンコードされているだけなので、誰でも中身を読めます。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ1c2VyIiwiZXhwIjoxNzY1MDAwMDAwfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

ヘッダーの中身: {"alg": "HS256", "typ": "JWT"}

ペイロードの中身: {"sub": "42", "role": "user", "exp": 1765000000}

まずは「サーバーで検証する」を押して、正規のトークンが通ることを確認してみてください。

※ 署名の再計算は概念モデルで再現しています(実際の検証では、サーバーが秘密鍵でHMAC-SHA256を再計算し、送られてきた署名と一致するかを比較します)。ポイントは、ペイロードを書き換えても「正しい新しい署名」は秘密鍵がないと作れない、という点です。

最小実装:Python標準ライブラリだけで作るJWT検証機

JWTの本質は外部ライブラリを使わずとも、わずか数十行のコードで完全に再現できます。

import base64
import hmac
import hashlib
import json
import time

SECRET_KEY = b"super-secret-system-key-2026"

def b64url_encode(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode('utf-8')

def b64url_decode(data: str) -> bytes:
    padding = '=' * (4 - (len(data) % 4)) if len(data) % 4 else ''
    return base64.urlsafe_b64decode(data + padding)

# 1. トークンの発行(署名作成)
def create_jwt(user_id: int) -> str:
    header = {"alg": "HS256", "typ": "JWT"}
    payload = {
        "sub": str(user_id),
        "exp": int(time.time()) + 3600  # 1時間有効
    }
    
    raw_header = b64url_encode(json.dumps(header).encode())
    raw_payload = b64url_encode(json.dumps(payload).encode())
    signing_input = f"{raw_header}.{raw_payload}".encode()
    
    # HMAC-SHA256 による署名
    signature = hmac.new(SECRET_KEY, signing_input, hashlib.sha256).digest()
    raw_signature = b64url_encode(signature)
    
    return f"{raw_header}.{raw_payload}.{raw_signature}"

# 2. トークンの検証(DBアクセスゼロ)
def verify_jwt(token: str) -> dict:
    parts = token.split('.')
    if len(parts) != 3:
        raise ValueError("不正なトークン形式です")
        
    raw_header, raw_payload, raw_signature = parts
    signing_input = f"{raw_header}.{raw_payload}".encode()
    
    # 手元の秘密鍵で署名を再計算し、タイミング攻撃を防ぐ比較を行う
    expected_sig = hmac.new(SECRET_KEY, signing_input, hashlib.sha256).digest()
    if not hmac.compare_digest(b64url_decode(raw_signature), expected_sig):
        raise ValueError("署名が改ざんされています!")
        
    payload = json.loads(b64url_decode(raw_payload).decode())
    if payload.get("exp", 0) < time.time():
        raise ValueError("トークンの有効期限が切れています")
        
    return payload

# 実験
token = create_jwt(42)
print("発行されたJWT:", token)
print("検証結果:", verify_jwt(token))

なぜJWTは万能薬になれなかったのか? —— 即時失効のパラドックス

「ステートレスでDBアクセスが不要」というJWTの最大の武器は、裏を返せばセキュリティ上の最大の弱点でした。

もしユーザーのスマートフォンが盗難に遭ったり、トークンがネットワーク上で漏洩した場合、どうやってそのトークンを止めればよいでしょうか?

😈 攻撃者 盗み出したJWT 有効期限はあと50分 ステートレス Webサーバー DBを見ない設計 無条件でアクセス許可! 署名も期限も数学的に正しい ★サーバー側から取り消す 手段がない! リクエスト 検証OK 保存していないトークンは、ピンポイントで無効化できない

サーバーはトークンを「保存していない」ため、「特定のトークンだけを無効化する」ことが物理的にできません。 これを防ぐために「無効化されたブラックリスト」をRedisに保存し始めた瞬間、それは**「重くて不便なCookieセッションへの先祖返り」**であり、JWTの存在意義そのものが消失してしまいます。

さらに、Base64エンコードされたJWTは数百バイト〜数キロバイトに肥大化し、毎リクエストのHTTPヘッダを圧迫するというネットワーク物理上のオーバーヘッドも無視できませんでした。


3. パスキー(WebAuthn)の夜明け —— 「秘密を渡さない」物理革命

セッションであれJWTであれ、旧来の認証システムには人間工学上の致命的な欠陥が横たわっていました。 **「パスワードやトークンという『共有秘密(Shared Secret)』を、人間がネットワーク越しにサーバーへ送信する」**という前提です。

どれほど強固なハッシュ関数を使っても、どれほど強固なセッション暗号を使っても、ユーザーが「本物そっくりの偽ログイン画面(フィッシングサイト)」にパスワードを打ち込んでしまえば、全ては一瞬で崩壊します。

この歴史に終止符を打つために登場したのが、**パスキー(FIDO2 / WebAuthn規格)**です。

1. 共有秘密の全廃 公開鍵暗号の採用 2. ハードウェア隔離 端末の Secure Enclave 3. Originバインディング ブラウザによるURL強制検証 サーバーがハックされても 平文パスワードが存在しない マルウェアに感染しても 秘密鍵の抽出が物理的に不可能 偽サイトにアクセスしても ブラウザが署名を物理的に拒絶!

パスキーは、**「秘密の文字列を教え合う」のを完全にやめ、「サーバーが出した数学のパズルに、端末内の秘密鍵でサインして送り返す」**という公開鍵暗号の物理へ移行しました。

USB接続のFIDOセキュリティキーの接写
パスキーの実物であるハードウェアセキュリティキー。秘密鍵はデバイス内部に閉じ込められ、外に出るのは署名だけだ。写真:Tony Webster(CC BY 2.0/Wikimedia Commons)

4. WebAuthnの認証物理 —— チャレンジ&レスポンスとドメイン拘束

パスキーによる認証がどのように動くのか、ブラウザとOS内部のハードウェアチップの挙動を追ってみましょう。

認証(ログイン)シーケンス

🎲 サーバー: 使い捨ての乱数「チャレンジ」を発行し、ブラウザへ送信 🌐 ブラウザ: 現在のURLオリジン = https://example.com を確定 🔐 認証器: 生体認証で本人確認 → Secure Enclave 内で秘密鍵で署名 📩 ブラウザ → サーバー: 署名 + クライアントデータを返却(秘密鍵は出ない) ✅ サーバー: 登録済みの「公開鍵」で署名を検証 → ログイン成功

なぜフィッシングが「物理的に不可能」なのか?

ここがパスキーの最も天才的な部分です。

攻撃者が https://examp1e.com(数字の1が混ざった巧妙な偽銀行サイト)を作り、被害者を誘導したとします。 被害者が「ログイン」ボタンを押すと、ブラウザ内部のJavaScriptエンジンが WebAuthn API(navigator.credentials.get())を呼び出します。

このとき、署名対象となるデータ(clientDataJSON)に含まれる origin(ドメイン名)は、攻撃者のスクリプトではなく、ブラウザ自身がアドレスバーの文字列を読み取って強制的にセットします。

{
  "type": "webauthn.get",
  "challenge": "a9f4c2...",
  "origin": "[https://examp1e.com](https://examp1e.com)"  <-- ブラウザが物理的に書き込む!
}

端末内のSecure Enclaveは、**「https://example.com(本物)用に作られた秘密鍵は、https://examp1e.com(偽物)の署名には絶対に使わせない」**というハードウェア制約を持っています。

マザーボード上のTPMチップ(Infineon SLB9655TT12)の接写
マザーボードに実装されたTPMチップ。Secure EnclaveやTPMのような専用チップが、秘密鍵を通常のメモリ空間から物理的に隔離して守る。写真:Raimond Spekking(CC BY-SA 4.0/Wikimedia Commons)

結果として、ユーザーがどれほど騙されていようとも、ブラウザと半導体チップが連携して署名の生成を拒絶します。偽サイトのサーバーには何の鍵も署名も届きません。 人間の注意力に依存しない、アーキテクチャによるフィッシングの完全根絶がここに達成されたのです。


5. 3方式の完全比較マトリクス —— トレードオフの力学

これら3つの方式は、どれか1つが絶対的な正義というわけではありません。それぞれが異なる物理的制約の上に成り立っています。

比較項目① Cookieセッション② JWT(ステートレス)③ パスキー(WebAuthn)
秘密情報のありかサーバー側(セッションストア)サーバー秘密鍵のみ(手元は署名文)端末の安全なハードウェア内部
サーバー状態管理ステートフル(水平スケールに負荷)完全ステートレス(DB参照不要)ステートレス検証(公開鍵のみ保持)
即時失効(ログアウト)極めて容易(行を消すだけ)原理的に不可能(有効期限切れを待つ)セッション管理層の設計に依存
フィッシング耐性脆弱(偽サイトに入力可能)脆弱(偽サイトに送信可能)完全防御(Origin拘束で物理遮断)
通信オーバーヘッド最小(短いランダム文字列)大きい(数百〜数千バイトのBase64)認証時のみ署名送受信(通常通信はCookie)
主なユースケースモノリスWeb、銀行、社内管理画面一時的な認可API、短命なDownload URL全Webサービスの次世代ログイン標準

現代の実務における最高水準のアーキテクチャは、これらを組み合わせたハイブリッド構成です。

「入り口の本人確認(認証)は、フィッシング耐性100%の『パスキー』で行い、ログイン成立後の日々の通信は、即時失効が効く『セッションCookie(SameSite=Lax付き)』で維持する」

互いの物理的弱点を補い合うこの構成こそが、現代のセキュリティ工学が辿り着いたひとつの頂点です。


📝 3行で頭に入る『試験・実務の要点まとめ』

  1. セッションとJWTの対立:Cookieセッションは即時無効化が得意だがスケールに弱く、JWTはDB不要でスケールするが「発行後の即時失効が原理的にできない」というトレードオフを抱える。
  2. パスキーの物理革命:パスワードの共有を全廃し、端末内の隔離チップ(TPM/Secure Enclave)で生成した公開鍵暗号署名を用いるため、サーバー漏洩時にも平文データが一切存在しない。
  3. フィッシングの完全封殺:ブラウザ自身が接続先ドメイン(Origin)を署名データに物理結合するため、人間が偽サイトに騙されてもハードウェアが署名発行を自動拒絶する。

確認クイズ

📝 確認クイズ(3問)