1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

パスワード認証、やめませんか? ― "non-password 認証" で設計する認証基盤

1
Last updated at Posted at 2026-10-28

はじめに

ニュースを開けば、ほぼ毎週のように個人情報流出が報じられます。
そのたびに続くのは「○○万件のハッシュ化パスワードが流出」「平文で保存されていた」というお決まりのフレーズです。

ところで、そもそもサービス側がパスワードを保存していなかったら、少なくとも「パスワード」部分は漏れようがない のではないでしょうか?

本記事のスコープ限定について

サーバ 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 つだけです:

  1. Magic Link 発行・確認: メアド → 10 分 expiry のトークン → URL → 消費
  2. サーバサイドセッション: HttpOnly Cookie に opaque な session ID、DB にハッシュ保存
  3. 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 つで実運用が可能:

  1. 自己キック禁止: 現在のセッションは自分で kick できない (misclick 対策)

  2. キック cooldown: 一度キックされたセッションは、再ログインから 10 分間 他をキックできない

  3. キックは 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. 段階移行可能:

  1. まず non-password 認証を既存ログインと 並列 で有効化
  2. ユーザがログインするたびに「次からはメールで入れますよ」を案内
  3. パスワード認証を deprecated にし、新規登録はメールのみに
  4. 一定期間後、既存パスワードを削除

実装例 (ダイジェスト版)

マジックリンク発行

// 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) で守る

パスワード流出事件への最良の対策は、「漏れるパスワードを持たないこと」 です。
ぜひ次の新規プロジェクトから検討してみてください。


参考情報


Happy non-password な認証ライフを!

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?