はじめに
ニュースを開けば、ほぼ毎週のように個人情報流出が報じられます。
そのたびに続くのは「○○万件のハッシュ化パスワードが流出」「平文で保存されていた」というお決まりのフレーズです。
ところで、そもそもサービス側がパスワードを保存していなかったら、少なくとも「パスワード」部分は漏れようがない のではないでしょうか?
本記事のスコープ限定について
サーバ root 権限奪取や DB の全ダンプ自体は、本記事の設計では 防げません。メアド・ユーザ属性・セッションのハッシュ値などは依然流出しえます。
本記事が扱うのは、その最悪ケースにおいても「パスワード」というカテゴリの流出だけは発生させない というスコープ限定の対策です。これは特に、他サービスへのパスワード使い回し被害 (credential stuffing) の遮断に効きます。
本記事ではその発想を実装に落とし込んだ 「non-password 認証」 の設計を紹介します。
Magic Link + HttpOnly Cookie + Passkey step-up を組み合わせて、
- ユーザからは「パスワードを覚える・リセットする」という体験が消え、
- 開発者からは「パスワードリセットメール」「パスワード強度ポリシー」「bcrypt のバージョン管理」が消え、
- セキュリティ的には「流出するパスワードが最初から存在しない」
という状態を実現します。
本記事は設計方針を凝縮したものです。実装の際は、下記の落とし穴の章および実装例を特にご確認ください。
要約 (TL;DR)
パスワードを捨てる。 メールの Magic Link でログインし、10 時間 HttpOnly Cookie のセッションで動かし、重要操作だけ Passkey で守る認証方式。
仕組み:
- Magic Link でログイン (10 分 expiry のメール内 URL)
- セッションは HttpOnly / Secure / SameSite=Lax クッキー、10 時間で絶対失効
- キック・メアド変更など重要操作は Passkey (WebAuthn) で step-up (L3)
メリット:
- ユーザ: パスワードを覚える・リセットする体験が消える
- 開発者: bcrypt / リセットフロー / パスワード強度ポリシー、全て不要
- セキュリティ: 保存するパスワードが存在しないので、DB 流出でもパスワードは漏れない
実装のキモ:
- 10 時間絶対失効 セッション (活動では延長しない)
- トークン / セッション ID は DB に hash 保存 (生値は保存しない)
- マジックリンクは GET → POST の 2 段階 (メールプリフェッチャ対策)
- 最新の magic link 1 通だけが生きる (新規発行前に過去の未消費を全て無効化)
- 全チャネルで登録者/非登録者の差を消す (応答コード・UI 文言・応答時間・レート制限・メールテンプレ)
- 重要操作は Passkey (L3) で守る
non-password 認証とは何か
ひとことで言うと:
「パスワードを一切保存せず、メールに届く有効期限付きリンクでログインする」認証方式。
セッションは HttpOnly Cookie に乗せ、重要操作だけ別途強い認証 (生体認証) で守る。
[ユーザ] メアド入力
│
▼
[サーバ] 有効期限 10 分のリンクをメール送信
│
▼
[ユーザ] メール内リンクをクリック → 確認ボタン押下
│
▼
[サーバ] セッション発行 (10 時間で絶対失効)
HttpOnly Cookie で返す
これだけ。「パスワードを入れる画面」が存在しません。
もう少し詳しく: サインインの完全なシーケンス
実際にサーバで何が起きているかを時系列で追うと、以下のようになります。
┌─────────┐ ┌──────────┐ ┌───────┐
│ Browser │ │ Server │ │ Mail │
└────┬────┘ └────┬─────┘ └───┬───┘
│ │ │
│ ① POST /auth/request │ │
│ { email } │ │
│──────────────────────────▶│ │
│ │ レート制限 (email / IP) │
│ │ raw = random(32 bytes) │
│ │ hash を DB に保存 │
│ │ (expires = NOW + 10min) │
│ │ │
│ │ ② メール送信 │
│ │──────────────────────────▶│
│ │ │
│ ③ 204 No Content │ │
│ (アカウント存在に関わらず常に 204 ← enumeration 対策) │
│◀──────────────────────────│ │
│ │ │
│ │ │
│ ... [ユーザが受信箱で "ログインリンク" をクリック] │
│ │ │
│ ④ GET /auth/magic?t=X │ │
│──────────────────────────▶│ │
│ │ ⑤ hash で DB を検索 │
│ │ ★ まだ consume しない │
│ │ │
│ ⑥ 確認ページを返す │ │
│ "foo@... として │ │
│ ログインしますか?" │ │
│ [確認ボタン + CSRF] │ │
│◀──────────────────────────│ │
│ │ │
│ ⑦ [確認ボタン押下] │ │
│ POST /auth/confirm │ │
│ { token, csrf } │ │
│──────────────────────────▶│ │
│ │ ⑧ atomic UPDATE で消費 │
│ │ (2 回クリック race 対策) │
│ │ ⑨ users に INSERT or SELECT │
│ │ ⑩ sessions に新規発行 │
│ │ expires = NOW + 10h │
│ │ elevated_at = NOW() │
│ │ ⑪ audit_log 'login' │
│ │ │
│ │ ⑫ 新端末なら通知メール │
│ │──────────────────────────▶│
│ │ │
│ ⑬ 302 → /dashboard │ │
│ Set-Cookie: sid=XXX; │ │
│ HttpOnly; Secure; │ │
│ SameSite=Lax; │ │
│ Max-Age=36000 │ │
│◀──────────────────────────│ │
ポイントは GET (④) ではまだ消費せず、POST (⑦) で初めて消費する 2 段階になっているところ。メールのプリフェッチャ対策で、後述する落とし穴 ① に直結します。
メリット
ユーザから見たメリット
| 従来のパスワード認証 | non-password 認証 |
|---|---|
| パスワードを覚える | 覚えない (メアドだけ) |
| サービスごとに違うパスワードを管理 | パスワード管理ツール自体が不要 |
| 流出事件のたびにパスワード変更 | 変更するものがそもそも無い |
| 「パスワードお忘れですか?」ボタンから再設定メール | 常にそのフローそのものがログイン |
| 定期的なパスワード変更要求にうんざり | 存在しない |
「ログイン」と「パスワード再設定」が同じ行為に統合される のが構造的に大きいです。
ユーザが「あれ、パスワード何だっけ…」とストレスを感じる瞬間がゼロになります。
開発者から見たメリット
これはかなり効きます。パスワード認証を支える裏側の面倒を ごっそり捨てられます:
- ❌ bcrypt / Argon2 のパスワードハッシュ化処理
- ❌ パスワード強度ポリシー (「大文字と記号を含めてください…」のアレ)
- ❌ パスワード変更フロー
- ❌ パスワードリセットフロー (「再設定メール」の URL 生成・有効期限・使い回し防止)
- ❌ 定期的なパスワード変更要求
- ❌ パスワード流出時のインシデントレスポンス
特に 「パスワードリセットメール」が丸ごと消える のが意外と大きい。
よく考えると、パスワード認証サービスは「ログインするためのメールフロー」と「パスワードを忘れた時のメールフロー」を 二重に実装している 状態でした。non-password 認証はこれを 1 つに統合するので、コードは本当にシンプルになります。
セキュリティ的メリット
| 攻撃 | パスワード認証 | non-password 認証 |
|---|---|---|
| DB 流出でパスワード大量漏洩 | 現実の脅威 | 保存するパスワードが存在しない |
| パスワード使い回し攻撃 (credential stuffing) | 刺さる | 刺さらない (そもそも password が無い) |
| フィッシングサイトでパスワード入力 → 攻撃者が本物に転用 | 刺さる | 原理的に不可。攻撃者はトークンを生成できず、本物のメアドに送られるため |
| ブルートフォース | レート制限で緩和するだけ | 攻撃対象の秘密がない |
パスワードは「ユーザが選んだ推測可能な文字列」である時点で、どれだけハッシュ化しても構造的な弱さが残ります。non-password 認証はこの弱さの 根本原因 (= パスワードの存在) を取り除きます。
デメリット・トレードオフ
正直な話として:
① メール依存
メールが届かないとログインできません。
送信の失敗、迷惑メールフォルダ、受信トレイの遅延、全てがログイン失敗になります。
- 対策: 10 分の有効期限 (届かなければ再送ボタンから再発行)、SPF/DKIM/DMARC の 3 点セット設定、transactional service (SES / Resend / Postmark) の利用
② メールアカウント乗っ取り時に詰む可能性
Gmail を乗っ取られたら、相手は自サービスのログインリンクも受け取れます。
- とはいえ: 既存のサービスも全て「パスワードリセットメール = メール受信者 = 本人」で動いており、同じリスクを抱えている。non-password 認証はこのリスクを隠さず正面から扱うだけ
- 対策: 後述する Passkey step-up で、重要操作だけはメール経由では昇格できなくする
③ UX: 毎回メールを待つのが遅い
30 秒くらいかかります。
対策: HttpOnly Cookie のセッションを 10 時間生かす ので、毎日 1〜2 回ログインすれば実質シームレス。
設計の全体像
┌────────────────────────┐
│ Mail Transfer Service │
│ (SES / Resend / etc.) │
└───────────▲────────────┘
│ 送信
┌───────────┐ HTTPS ┌─────────────┴──────────────┐
│ Browser │ ──────▶ │ Auth Server │
│ │ │ │
│ HttpOnly │ ◀────── │ - Magic Link 発行・確認 │
│ Cookie: │ │ - セッション管理 │
│ sid=... │ │ - Passkey step-up │
└───────────┘ │ │
│ ┌─────────────────┐ │
│ │ PostgreSQL │ │
│ │ users │ │
│ │ magic_tokens │ │
│ │ sessions │ │
│ │ passkeys │ │
│ │ audit_log │ │
│ └─────────────────┘ │
└─────────────────────────────┘
キモになるコンポーネントは 3 つだけです:
- Magic Link 発行・確認: メアド → 10 分 expiry のトークン → URL → 消費
- サーバサイドセッション: HttpOnly Cookie に opaque な session ID、DB にハッシュ保存
- Passkey step-up: 重要操作だけ生体認証で守る
コア技術選定: なぜその選択?
なぜ HttpOnly Cookie? (localStorage ではなく)
localStorage に token を置くと、XSS 1 発で全部盗まれます。
HttpOnly Cookie は JavaScript から読めない ので、サイト上に XSS 脆弱性があってもセッションは盗まれません。
| HttpOnly Cookie | localStorage | |
|---|---|---|
| XSS で盗まれる | ✗ (JS 不可視) | ○ |
| 自動送信 | ○ | 手動 |
| サーバから無効化 | ○ (Set-Cookie 上書き) | ✗ |
なぜ ステートフルセッション? (JWT ではなく)
JWT (ステートレス) は 取り消しできません。
「不審な端末をキックする」機能が原理的に実現不可能です。短い expiry + refresh token でも結局 refresh token を取り消すためのサーバ側状態が必要になり、結局ステートフルです。
→ 最初から素直にサーバ側セッションを持つ のが正解。
なぜ 10 時間絶対失効?
ユーザが「残業で延長されない」確実な上限を作るため。
一般的な勤務時間 (8h + 休憩 1h + 残業 1h) をカバーしつつ、万一セッションを盗まれても最大被害時間が 10h で頭打ちです。
スライディング失効 (活動で延長) は採用しません。延長されると長期セッションがずるずる生き続け、盗難時の被害が青天井になります。
なぜ Passkey step-up?
パスワードリセットメール耐性のあるマジックリンクでも、「メールアカウント乗っ取り」には勝てません。
そこで 重要操作 (退会・メアド変更・端末キック・Passkey 登録/削除、サービスによってはクレカ変更・高額決済・admin 権限付与・API キー発行なども) だけはパスキー (= 端末生体認証) を要求 します。
これにより:
- 攻撃者が Gmail を取っても、Passkey が無ければ重要操作に進めない
- 本物のユーザは指紋タッチ 1 秒で L3 操作が通る
- Passkey は origin 束縛でフィッシング耐性も完全
実装のポイント
ポイント ① マジックリンクは 2 段階 (GET → POST)
これを知らずに実装すると詰みます:
❌ ダメな実装:
GET /auth/magic?token=XXX → 即消費してセッション発行
なぜダメ? Microsoft SafeLinks や Gmail のセキュリティスキャナが受信後に自動で GET アクセスします。
ユーザが開く前にトークンが消費されてしまい、ユーザがログインできなくなります。
✅ 正しい実装:
GET /auth/magic?token=XXX → 確認ページを返すだけ (消費しない)
"foo@example.com としてログインしますか?" [確認ボタン]
POST /auth/confirm → ここで初めて atomic に消費 + セッション発行
スキャナは GET しかしないので、トークンが保護されます。
ポイント ② トークン・セッション ID は hash で保存
import { randomBytes, createHash } from "node:crypto";
// 発行時
const raw = randomBytes(32);
const hash = createHash("sha256").update(raw).digest();
// → raw を URL/Cookie に、hash を DB に保存
// 検証時
const incoming = Buffer.from(cookieValue, "base64url");
const incomingHash = createHash("sha256").update(incoming).digest();
// → DB で incomingHash を検索
なぜ hash? DB が万一流出しても、hash からは生の token を復元できないので、流出直後にセッション乗っ取り祭りにはなりません。
ポイント ③ 権限を L1 / L3 の 2 層で管理
| レベル | 判定条件 | 用途 |
|---|---|---|
| L1 (通常) | 有効セッションを持っている | コンテンツ閲覧、通常操作 |
| L3 (昇格) | 直近 5 分以内に再認証した | キック・メアド変更・退会・Passkey 登録 |
ログイン直後は 5 分間 L3 として扱います。5 分経過で自動的に L1 に降格。
L3 への昇格は Passkey (推奨) またはマジックリンク再送 (fallback)。
ポイント ④ 他端末キック時の防衛三原則
ログインキック機能はよく「相互キック戦争」が心配されますが、以下の 3 つで実運用が可能:
-
自己キック禁止: 現在のセッションは自分で kick できない (misclick 対策)
-
キック cooldown: 一度キックされたセッションは、再ログインから 10 分間 他をキックできない
-
キックは L3 必須 + 非対称性:
- 正規ユーザ (Passkey 登録済) → 指紋タッチ 1 秒で L3 → 即キック可
- 攻撃者 (メールのみ) → マジックリンク到着を待つ必要あり → 本人の受信箱に通知が届くので即バレる
この速度差で kick 戦争は本物側の圧勝になります。
キック実行までのシーケンス (Passkey で L3 昇格)
L1 セッションから「他端末をキックする」までの流れです。重要操作なので一度 401 で跳ね返されて Passkey 認証 を挟んでから、改めてキックが成立します。
┌─────────┐ ┌──────────┐
│ Browser │ │ Server │
└────┬────┘ └────┬─────┘
│ │
│ ① POST /me/sessions/ │
│ :target/revoke │
│──────────────────────────────▶│
│ │ ② L3 判定: elevated_at が null
│ │ または 5min 超過 → NG
│ ③ 401 { require_elevation, │
│ methods:[passkey,…]} │
│◀──────────────────────────────│
│ │
│ ④ POST /webauthn/ │
│ elevate/options │
│──────────────────────────────▶│
│ │ ⑤ challenge 生成
│ │ session に紐付け
│ ⑥ options { challenge, … } │
│◀──────────────────────────────│
│ │
│ ⑦ navigator.credentials.get()│
│ → ★ 指紋 / Face ID │
│ → 端末内の秘密鍵で署名 │
│ │
│ ⑧ POST /webauthn/ │
│ elevate/verify │
│ { credential, signature } │
│──────────────────────────────▶│
│ │ ⑨ 公開鍵で署名検証
│ │ ⑩ signCount 単調増加チェック
│ │ ⑪ expectedOrigin / RPID 検証
│ │ ⑫ UPDATE sessions:
│ │ elevated_at = NOW()
│ │ elevation_method = 'passkey'
│ │ ⑬ audit_log 'elevated'
│ ⑭ 204 │
│◀──────────────────────────────│
│ │
│ │
│ ⑮ POST /me/sessions/ │ ★ 元の操作を再実行
│ :target/revoke │
│──────────────────────────────▶│
│ │ ⑯ L3 判定: OK
│ │ ⑰ 自己キック禁止 check
│ │ ⑱ キック cooldown check
│ │ ⑲ レート制限 check (1h / 3 回)
│ │ ⑳ UPDATE sessions
│ │ SET revoked_at = NOW()
│ │ WHERE id_hash = target
│ │ AND user_id = current ★
│ │ ㉑ audit_log 'kick'
│ │ ㉒ キックされた端末に通知メール
│ ㉓ 204 │
│◀──────────────────────────────│
攻撃者がメールしか持っていなければ、⑧ の Passkey 署名ができません。マジックリンクでの L3 昇格に逃げようとしても、昇格リンクのメールが本人の受信箱に届く → 即バレる という構図になります。
ポイント ⑤ 「あなたの知らない端末」通知
ログイン時に、過去 30 日のセッションに同じ UA/IP パターンがなければ ユーザに通知メール を送ります:
件名: 新しい端末からログインしました
日時: 2026-10-28 09:12 JST
端末: Chrome 130 on macOS
場所: 東京 (103.4.x.x)
心当たりがない場合は以下から該当端末をキックしてください:
https://app.example.com/me/sessions
ユーザに「警戒しろ」と要求せず、異常があったときだけ気付けるようにする のがコツです。
クッキー設定の最適解
これだけ覚えておけば OK:
Set-Cookie: sid=<base64url(randomBytes(32))>;
Max-Age=36000; # 10h
Path=/;
HttpOnly; # ★ JS から読めない
Secure; # ★ HTTPS のみ
SameSite=Lax; # ★ CSRF 対策
各属性の意味:
- HttpOnly: JavaScript から読めない → XSS 対策
- Secure: HTTPS 以外には送信しない → 盗聴対策
- SameSite=Lax: 他サイトからの POST では送られない → CSRF 対策
- Max-Age=36000: 10 時間で自動的に期限切れ
SameSite=Strict だと、メーラ (別オリジン) からマジックリンクをクリックした直後に cookie が付かず挙動が壊れることがあります。Lax が最適です。
よくある落とし穴 TOP 7
実装のときに踏みがちなワナです。
① メールプリフェッチャによる勝手な消費
→ 前述の GET → POST の 2 段階 で対応。
② Session Fixation
認証前に発行された session_id が、認証後もそのまま使われていると、攻撃者が被害者にその id を使わせて乗っ取ることができます。
対策: ログイン成功時に 必ず新しい session_id を発行し、古い cookie は破棄する。
③ Email Enumeration (★全チャネルを塞ぐ必要あり)
攻撃者はメアドを総当たりで /auth/request に投げて、システムに登録されているかどうかを特定 しようとします (ユーザリスト作成攻撃)。
対策として「HTTP ステータスを揃える」だけでは 全く不十分 です。
攻撃者は UI 文言・応答時間・レート制限の挙動・メール送信内容 のどのチャネルからでも登録者/非登録者を見分けようとします。全チャネルで差を消さないといけません。
③-1. 画面の文言 (最も見落とされがち)
| ❌ ダメな文言 | ✅ 推奨文言 |
|---|---|
| 「アカウントが存在しません」 | 分岐自体をなくす |
| 「マジックリンクを送信しました」 (登録者のみ表示) | 「ご入力のアドレスにメールを送信しました (登録済みの場合)」 |
| 「このメアドは未登録です」 | 出さない |
| 「このメアドは既に登録されています」(サインアップ画面) | サインアップ画面自体を廃止、ログインと統合 |
一番の鍵は 「ログイン画面」と「新規登録画面」を 1 つに統合する こと。メアド入力画面 1 枚で両方捌けば、画面文言から判別する余地がなくなります。
③-2. 応答時間 (timing attack)
同じ 204 でも、処理時間に差が出るとベンチマークで判別可能:
- 登録済み:
SELECT user WHERE email=?でヒット + token INSERT + メール送信キュー投入 → 数 ms - 未登録:
SELECT user WHERE email=?でヒットなし → 即終了 → 1 ms
1000 回測定で平均化すれば、数 ms の差でも余裕で検出されます。
対策:
- メール送信は 非同期キューに積むだけ にして、同期パスから外す
- 未登録でも DB 書き込みパスを通す (ダミーの magic_tokens を INSERT、本物のメール送信はスキップ)
- または応答時間を定数にクランプ:
const start = Date.now(); await handleRequest(email); const elapsed = Date.now() - start; if (elapsed < 200) await sleep(200 - elapsed); res.status(204).end();
③-3. レート制限の挙動
ダメな例: 「メアド単位で 10 分 3 通」を素直に実装すると、
- 登録済みメアドに 4 通投げる → 本人のレート で制限 → 429
- 未登録メアドに 4 通投げる → 誰も使ってない → 429 にならない
→ 429 の有無で登録済みかバレます。
対策: 制限ヒット時も 常に 204 を返す。送信は内部でスキップするだけ。「レート制限ヒット」はログ・メトリクスだけに残す。
③-4. メール送信の違い
- 新規登録 welcome メールと「ログインリンク」メールを別テンプレートにすると、受信者本人なら区別できてしまう
- PoC では 初回も再ログインもテンプレ同一 (「ログインリンクです」のみ) にする
- 「ようこそ」は初回ログイン完了後、アプリ内画面で出す
④ セッションを UA / IP に拘束しちゃう
「不正アクセス防止だ!」と思って session を UA / IP に紐付けると、モバイルユーザは WiFi と 4G の切り替えで頻繁にログアウトされます。
対策: 拘束しない。表示するだけ。ユーザが異常を気付く材料にする。
⑤ 他人のリソースを操作できてしまう
-- ❌ 危険
UPDATE sessions SET revoked_at=NOW() WHERE id_hash = $1
-- ✅ 正しい (user_id チェック必須)
UPDATE sessions SET revoked_at=NOW()
WHERE id_hash = $1 AND user_id = $2
対策: /me/* 系の全エンドポイントで 必ず WHERE user_id = current_user.id を SQL に入れる。
テストで「他人の ID を直接叩いて 404 になること」を検証。
⑥ 複数通発行された magic link の併存 (★超重要)
ユーザが「メール来ない!」と /auth/request を 5 回連打すると、メールボックスに 5 通のログインリンク が届きます。
この 5 通が全部有効のまま だと以下の攻撃が成立します:
09:00 ユーザ: 連打で 5 通発行、5 通目でログイン成功
→ link A〜D は未使用のまま expiry 内 (10 分間) 生存
09:05 攻撃者: 共有 PC のメーラに残っていた link A を叩く
→ ★ログイン成功★ (link A はまだ生きていた)
対策: /auth/request では 新規発行の前に、同じ (email, purpose) の未消費 token を全部無効化 します。
BEGIN;
-- ★ 新規発行の前に既存の未消費 token を全て consumed にする
UPDATE magic_tokens
SET consumed_at = NOW()
WHERE email = $1 AND purpose = $2 AND consumed_at IS NULL;
INSERT INTO magic_tokens (token_hash, email, purpose, expires_at, ...)
VALUES ($3, $1, $2, NOW() + interval '10 min', ...);
COMMIT;
これで 「同時に生きている magic link は常に 1 つ以下」 という不変条件が成立します。10 分の expiry と組み合わせると、生きている link の数が爆発することがなくなります。
UX への配慮: ユーザが「1 通目のメール」を押すと「無効です」になる (混乱の種) ので、送信完了画面に案内を出しておきましょう:
💡 複数回送信した場合は、必ず 最後に届いたメール のリンクをご使用ください。
以前のメールのリンクは無効になっています。
⑦ Open Redirect
ログインリンクに ?redirect_to=https://evil.com を付けられると、ログイン後にフィッシングサイトに飛ばせます。
対策:
-
redirect_toは 許可リスト にする - または 相対 URL のみ 許容 (
/始まり) -
//始まり (protocol-relative)、javascript:、data:は弾く
応用: アカウント乗っ取りを封じるメアド変更フロー
セッションを 1 つ取られた攻撃者がよくやるのが「メアドをこっそり自分のものに書き換えて永続化」です。
これを防ぐため、メアド変更は 新旧の両方に確認メールを送り、どちらからでも操作できる 2 段階にします。
┌─────────┐ ┌──────────┐ ┌───────────┐
│ Browser │ │ Server │ │ Mail (×2) │
└────┬────┘ └────┬─────┘ └─────┬─────┘
│ │ │
│ ① POST /me/email/ │ │
│ change │ │
│ { new_email } │ │
│ ★ L3 required │ │
│───────────────────────▶│ │
│ │ ② 新メアド宛送信 │
│ │ purpose='email_ │
│ │ change_to' │
│ │ payload={new_email} │
│ │─────────────────────────▶│ → 新メアド
│ │ │ "確認してください"
│ │ ③ 旧メアド宛送信 │
│ │ purpose='email_ │
│ │ change_abort' │
│ │ payload={new_email, │
│ │ pending_id} │
│ │─────────────────────────▶│ → 旧メアド
│ │ │ "心当たりなければ中止"
│ ④ 204 │ │
│◀───────────────────────│ │
│ │ │
│ │ │
│ ★ ケース A: 本人が新メアド側の確認リンクをクリック │
│ │ │
│ ⑤ POST /auth/ │ │
│ email_change/ │ │
│ confirm { token } │ │
│───────────────────────▶│ │
│ │ ⑥ BEGIN │
│ │ - atomic consume │
│ │ (email_change_to) │
│ │ - UPDATE users │
│ │ SET email = new │
│ │ - UPDATE sessions │
│ │ 全セッション失効 │
│ │ - 対応する abort │
│ │ トークンも consume │
│ │ - audit_log │
│ │ COMMIT │
│ ⑦ 302 → 再ログイン │ │
│◀───────────────────────│ │
│ │ │
│ │ │
│ ★ ケース B: 旧メアド所有者が「身に覚えなし」で中止 │
│ │ │
│ ⑧ POST /auth/ │ │
│ email_change/ │ │
│ abort { token } │ │
│───────────────────────▶│ │
│ │ ⑨ BEGIN │
│ │ - atomic consume │
│ │ (email_change_abort) │
│ │ - 対応する confirm │
│ │ トークンも consume │
│ │ - UPDATE sessions │
│ │ 念のため全失効 │
│ │ - audit_log │
│ │ COMMIT │
│ ⑩ 200 "変更を中止" │ │
│◀───────────────────────│ │
この設計により:
- 攻撃者がセッションだけで メアドを変更しようとしても、旧メアドの本人に即座に abort メールが届く
- 本人が気付けば abort クリック 1 発で終了、しかも 保険として全セッション失効
- 変更が完了するケースでも、確定後に全セッション失効 → 本人も新メアドで再ログイン
→ 「セッションを取られた瞬間に負け確定」ではなく、メアド所有者が救済できる余地 が常にあります。これはユーザが「警戒する」必要すらなく、旧メアドに届いたメールにさえ気付ければ 守られる、低負荷な仕組みです。
FAQ
Q. JWT じゃダメなの?
A. JWT (ステートレス) だと 取り消しができません。「不審な端末をキックする」機能が原理的に不可能になるので、本設計では採用しません。
Q. Passkey だけでいいんじゃない?
A. 理想ではあります。ただし現状、新規ユーザの環境依存が強く、デバイス紛失時の復旧がシビア。
マジックリンクを first factor、Passkey を L3 step-up とすることで、どちらの利点も取れるハイブリッドが現実解です。
Q. SMS OTP は?
A. SIM swap リスク、国際配信コストと到達性の問題で採用しづらいです。
Q. メールアカウントを乗っ取られたら結局終わりでは?
A. マジックリンクだけなら Yes。ただし 重要操作 (キック・メアド変更・退会) は Passkey 必須 にすることで、Gmail を失っても最低限の守りが残ります。「ユーザが Passkey を 1 つでも登録している」ことが鍵です。
Q. パスワードリセットメールが要らなくなるってことは、「ログインメール = リセットメール」なの?
A. その通りです。両者は構造的に同じ「メールで本人確認して認証する」行為なので、統合すればコードはシンプル、ユーザのメンタルモデルも 1 つに統一されます。
Q. 既存システムからの移行はどうする?
A. 段階移行可能:
- まず non-password 認証を既存ログインと 並列 で有効化
- ユーザがログインするたびに「次からはメールで入れますよ」を案内
- パスワード認証を deprecated にし、新規登録はメールのみに
- 一定期間後、既存パスワードを削除
実装例 (ダイジェスト版)
マジックリンク発行
// POST /auth/request
async function request(req, res) {
const email = normalize(req.body.email);
await rateLimit(email, req.ip);
const raw = randomBytes(32);
const hash = createHash("sha256").update(raw).digest();
await db.query(`
INSERT INTO magic_tokens (token_hash, email, purpose, expires_at)
VALUES ($1, $2, 'signin', NOW() + interval '10 minutes')
`, [hash, email]);
await sendMail(email, {
subject: "ログインリンク",
link: `https://app.example.com/auth/magic?t=${raw.toString("base64url")}`,
});
// ★ アカウント存在に関わらず常に 204 (enumeration 対策)
res.status(204).end();
}
確認とセッション発行
// POST /auth/confirm
async function confirm(req, res) {
const raw = Buffer.from(req.body.token, "base64url");
const hash = createHash("sha256").update(raw).digest();
// ★ atomic consume (2 クリック race 対策)
const row = await db.oneOrNone(`
UPDATE magic_tokens
SET consumed_at = NOW()
WHERE token_hash = $1
AND consumed_at IS NULL
AND expires_at > NOW()
RETURNING email, user_id, purpose
`, [hash]);
if (!row) return res.status(400).end();
// 既存ユーザならそのまま、新規なら INSERT
const user = await ensureUser(row.email);
// ★ 新しい session_id を発行 (fixation 対策)
const sid = randomBytes(32);
const sidHash = createHash("sha256").update(sid).digest();
await db.query(`
INSERT INTO sessions (id_hash, user_id, expires_at, elevated_at, elevation_method,
user_agent, ip_address)
VALUES ($1, $2, NOW() + interval '10 hours', NOW(), 'magic_link', $3, $4)
`, [sidHash, user.id, req.headers["user-agent"], req.ip]);
res.setHeader("Set-Cookie",
`sid=${sid.toString("base64url")}; Max-Age=36000; Path=/; HttpOnly; Secure; SameSite=Lax`);
res.redirect("/dashboard");
}
セッション検証 middleware
async function requireSession(req, res, next) {
const raw = req.cookies.sid;
if (!raw) return redirect(res, "/login");
const hash = createHash("sha256").update(Buffer.from(raw, "base64url")).digest();
const session = await db.oneOrNone(`
SELECT * FROM sessions
WHERE id_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()
`, [hash]);
if (!session) {
res.setHeader("Set-Cookie", "sid=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax");
return redirect(res, "/login");
}
req.session = session;
req.userId = session.user_id;
next();
}
function requireElevated(req, res, next) {
const e = req.session.elevated_at;
if (!e || Date.now() - e.getTime() > 5 * 60_000) {
return res.status(401).json({ require_elevation: true });
}
next();
}
これだけで骨格は動きます。あとは L3 昇格 (Passkey)・キック・メアド変更・バックアップコードを足せば、production-ready な認証基盤になります。
まとめ
- non-password 認証: マジックリンク + HttpOnly Cookie + Passkey step-up のハイブリッド
- ユーザのメリット: パスワード関連の全ストレスが消滅
- 開発者のメリット: パスワードハッシュ / リセットフロー / 強度ポリシーが消滅
- セキュリティのメリット: 保存するパスワード自体が無いので、DB が丸ごと吸い出されても 「パスワード」というカテゴリの流出は発生しない (他サービスへの使い回し被害を遮断できる)
-
実装のキモ:
- 10h の 絶対失効 セッション (活動では延長しない)
- トークン / セッション ID は DB に hash 保存 (生値は保存しない)
- マジックリンクは GET → POST の 2 段階 (プリフェッチャ対策)
- 最新の magic link 1 通だけが生きる (新規発行前に過去の未消費を全無効化)
- 全チャネルで登録者/非登録者の差を消す (応答コード・UI 文言・応答時間・レート制限・メールテンプレ)
- 重要操作は L3 (Passkey) で守る
パスワード流出事件への最良の対策は、「漏れるパスワードを持たないこと」 です。
ぜひ次の新規プロジェクトから検討してみてください。
参考情報
- WebAuthn 実装: @simplewebauthn/server
- トランザクショナルメール: Amazon SES / Resend / Postmark
- クッキーの SameSite 仕様: MDN Set-Cookie
Happy non-password な認証ライフを!