コンテンツにスキップ

マジックリンクと SMS OTP

マジックリンクと SMS OTP — NIST SP 800-63B-4

  • どちらも同じ構造。 使い捨ての秘密を作り、利用者しか受け取れないはずの別チャネルへ送り、 返ってきた値を突き合わせる。違うのはチャネルと秘密の長さだけ。
  • NIST は「メールを out-of-band 認証に使ってはならない」と明記している。 SP 800-63B-4 §3.1.3 の Email SHALL NOT be used for out-of-band authentication。 マジックリンクもメール OTP も、NIST の枠組みでは out-of-band 認証器にあたらない
  • SMS は禁止ではなく restricted 使ってよいが、代替手段の提供・利用者への通知・ リスク評価・移行計画の 4 つが SHALL で課される(§3.2.9)。

マジックリンクにも SMS OTP にも専用の RFC はない。 規範的な要求は NIST にあり、 実装上の指針は OWASP にある。この区別を意識して読む。

文書 位置づけ この記事で扱う範囲
NIST SP 800-63B-4 2025-07-31 発行。規範的 out-of-band の定義、メールの排除、SMS の restricted
OWASP Forgot Password Cheat Sheet 実装指針 トークンの扱い、アカウント存在の秘匿
OWASP Session Management 実装指針 エントロピーの比較対象(64 bit)
RFC 9989 DMARC。2026-05 に RFC 7489 を廃止 メールの送信元認証が何を保証するか
FCC 23-95 米国の規則(2024-07-08 施行) SIM スワップ対策として事業者に課された義務

NIST はメールを out-of-band 認証と認めていない

Section titled “NIST はメールを out-of-band 認証と認めていない”

ここが最初に押さえるべき点。 SP 800-63B-4 §3.1.3 の out-of-band 認証器の定義:

An out-of-band authenticator is a physical device that is uniquely addressable and can communicate securely with the verifier over an independent communications channel

「物理デバイス」であることが定義に含まれる。 そしてメールは名指しで排除される。

Email SHALL NOT be used for out-of-band authentication because it may be vulnerable to: Access using only a password; Interception in transit or at intermediate mail servers; Rerouting attacks, such as those caused by Domain Name System (DNS) spoofing

理由が 3 つ挙げられている。

理由 意味
“Access using only a password” メールボックス自体がパスワードだけで開く。 多要素が 1 要素に戻る
“Interception in transit or at intermediate mail servers” 中継サーバーで読まれうる
“Rerouting attacks … DNS spoofing” MX レコードを乗っ取れば配送先を変えられる

1 つ目が本質。 マジックリンクを「パスワードの代わり」に使うと、 利用者のメールパスワードが実質的なパスワードになるだけで、要素は増えていない。

flowchart TB
    S["サーバーが使い捨ての秘密を作る"]
    C{"どのチャネルで送るか"}
    M["メール<br/>マジックリンク / メール OTP"]
    P["SMS / 音声<br/>PSTN"]
    V["返ってきた値を突き合わせる"]

    S --> C
    C --> M --> V
    C --> P --> V

    M --> M1["NIST の out-of-band 認証器では<br/>ない (SHALL NOT)"]
    P --> P1["out-of-band ではあるが<br/>restricted"]

    style M1 fill:#7a2222,color:#fff
    style P1 fill:#7a4a12,color:#fff
マジックリンク メール OTP SMS OTP
送るもの URL(クリックする) 数字(手入力する) 数字(手入力する)
秘密の長さ 任意に長くできる(256 bit も可) 6 桁 = 19.93 bit が実務の下限 同左
NIST の分類 out-of-band ではない out-of-band ではない out-of-band だが restricted
固有の脅威 リンクの先読み、別端末で開かれる SIM スワップ、番号ポーティング
共通の脅威 チャネルの乗っ取り、中継フィッシング 同左 同左

メールに届いたマジックリンクと、SMS に届いた 6 桁コードの見た目の対比

利用者が受け取るのはこの 2 つ。 左はクリックするだけ、右は目で読んで打ち込む。 この差がフィッシング耐性を生むことはない後述)が、 秘密の長さの差はここから来ている。

sequenceDiagram
    autonumber
    participant U as 利用者 (ブラウザ)
    participant S as サーバー
    participant M as メールボックス

    U->>S: メールアドレスを入力
    S->>S: 32 バイトの乱数を生成<br/>ハッシュを保存 (10 分有効)
    S->>M: リンク入りのメールを送る
    S-->>U: 常に同じ文言を返す<br/>アカウントの有無を漏らさない
    U->>M: メールを開く
    M-->>U: リンクをクリック
    U->>S: GET /login/verify?token=...
    S->>S: ハッシュを突き合わせ<br/>期限と未使用を確認<br/>即座に無効化
    S-->>U: セッション Cookie を発行

4 番で「送りました」としか返さない。 アカウントが存在するかどうかを答えると、 メールアドレスの総当たりで会員名簿が作れる

sequenceDiagram
    autonumber
    participant U as 利用者 (ブラウザ)
    participant S as サーバー
    participant T as 電話番号 (PSTN)

    U->>S: ID とパスワードでログイン
    S->>S: 6 桁の乱数を生成<br/>ハッシュを保存 (10 分有効)
    S->>T: SMS を送る
    S-->>U: コードの入力欄を出す
    U->>S: 6 桁を入力
    S->>S: 突き合わせ<br/>レート制限を確認<br/>即座に無効化
    S-->>U: セッション Cookie を発行

SMS は 2 要素目として使う。 1 要素目のパスワードがあって初めて成立する。 マジックリンクのように単独で使うと、電話番号を奪われた時点で終わりになる。

マジックリンクと OTP でここが決定的に違う。

マジックリンク(32 バイト = 256 bit)
raw(hex) = c8e1046b93af27d5e40b8c1f7a3d62905fb4e8712c6d0a934e7f21b58c0d3946
base64url = yOEEa5OvJ9XkC4wfej1ikF-06HEsbQqTTn8htYwNOUY (43 文字)
URL = https://example.com/login/verify?token=yOEEa5OvJ9XkC4wfej1ikF-06HEsbQqTTn8htYwNOUY
ターミナルウィンドウ
openssl rand 32 | base64 | tr '+/' '-_' | tr -d '='
# yOEEa5OvJ9XkC4wfej1ikF-06HEsbQqTTn8htYwNOUY

OTP のほうは NIST が下限を定めている(§3.1.3.2):

The verifier SHALL generate random authentication secrets that are at least six decimal digits (or equivalent) in length using an approved random bit generator

「approved random bit generator」が付いているのを見落とさない。 Math.random()rand() で 6 桁を作るのは仕様違反。

同じ「使い捨ての秘密」なのに、桁が違う。

エントロピー 総当たりの期待試行数
6 桁コード 19.93 bit 5.0 × 105
8 桁コード 26.58 bit 5.0 × 107
セッション ID の OWASP 最小 64 bit 9.2 × 1018
マジックリンクのトークン(32 バイト) 256 bit 5.8 × 1076

OWASP の要求は “Session identifiers must have at least 64 bits of entropy” — つまり 6 桁コードはセッション ID の下限の 3 分の 1 以下しかない。

NIST §3.1.3.2 が定める要求:

項目 規範 文言
有効期限 SHALL “the authentication SHALL be considered invalid unless completed within 10 minutes
単回使用 SHALL “SHALL accept a given authentication secret as valid only once during the validity period”
レート制限 SHALL 64 bit 未満なら実装する
連続失敗 SHALL(§3.2.2) “no more than 100” で認証器を無効化

10 分は仕様の数字。 「24 時間くらいが普通」という記述をよく見るが、 NIST にも OWASP にもその数字は無い。NIST の out-of-band の規定は 10 分で、 マジックリンクはそもそも NIST の out-of-band ではないので直接は適用されないが、 10 分より長くする根拠はどこにも無い

flowchart TB
    IN["トークン / コードを受け取る"]
    R{"レート制限に<br/>引っかかっていないか"}
    H{"保存したハッシュと<br/>一致するか"}
    E{"発行から<br/>10 分以内か"}
    U{"未使用か"}
    OK["認証成功<br/>即座に無効化してセッションを発行"]
    NG["失敗<br/>理由は返さない"]

    IN --> R
    R -->|"超過"| NG
    R -->|"以内"| H
    H -->|"不一致"| NG
    H -->|"一致"| E
    E -->|"超過"| NG
    E -->|"以内"| U
    U -->|"使用済み"| NG
    U -->|"未使用"| OK

    style OK fill:#1f5c33,color:#fff
    style NG fill:#7a2222,color:#fff

保存するのはトークンそのものではなくハッシュ。

token = yOEEa5OvJ9XkC4wfej1ikF-06HEsbQqTTn8htYwNOUY
SHA-256 = b454bff0afad1be5ba3feef5860eef8724aca7da6311f7b1b2dd1b2d1e308bcd ← これを保存する
ターミナルウィンドウ
printf '%s' 'yOEEa5OvJ9XkC4wfej1ikF-06HEsbQqTTn8htYwNOUY' | openssl dgst -sha256
# b454bff0afad1be5ba3feef5860eef8724aca7da6311f7b1b2dd1b2d1e308bcd

失敗しても消す。 「一致しなかったら残す」実装だと、 同じトークンに何度も挑戦できてしまう。

  • 一致したら 即座に無効化SHALL の単回使用)
  • 新しいリンクを発行したら古いものを失効させる
  • ログイン成功時にセッション ID を再生成するセッション固定攻撃の対策と同じ)

リンクが利用者より先に押される

Section titled “リンクが利用者より先に押される”

メールのセキュリティゲートウェイがリンクを開く。 Microsoft の Safe Links は、公式ドキュメントでこう説明されている:

URL scanning and rewriting of inbound email messages in mail flow, and time-of-click verification of URLs and links in email messages

配送時点でスキャンし、クリック時点でも再検証するという二段構成。 単回使用のトークンが、利用者がクリックする前に消費されうる。

sequenceDiagram
    autonumber
    participant S as サーバー
    participant G as メールゲートウェイ
    participant U as 利用者

    S->>G: マジックリンク入りのメール
    G->>S: GET /login/verify?token=...<br/>スキャンのために開く
    S->>S: トークンを使用済みにする
    G-->>U: メールを配送
    U->>S: リンクをクリック
    S-->>U: 「このリンクは無効です」

配達途中の検査官が、一度しか押せないボタンを先に押してしまう比喩

単回使用が仇になる。 「一度しか使えない」という正しい設計が、 「配送経路の誰かが先に使う」と噛み合わない。

対策は「GET でログインを完了させない」。

方式 先読みに耐えるか
GET /verify?token=... で即ログイン 耐えない
GET では確認画面を出し、POST でログイン確定 耐える
リンクに加えて同じ画面に短いコードを表示させる 耐える

確認画面を挟むのは UX の妥協ではなく、機能要件。

メールをスマホで開き、ログインしたいのは PC、という状況が普通に起きる。

方式 挙動
トークンだけで判定 どの端末からでも入れる。 便利だが、リンクが漏れたら誰でも入れる
開始した端末に紐づける(発行時のセッションと突き合わせる) 安全だが別端末では入れない
確認コードを併用(開始画面に数字を出し、メール側にも同じ数字を載せる) 別端末でも安全に確定できる

3 つ目が実務の落としどころ。 「画面に出ている数字と、メールに書いてある数字が 同じことを確認してください」という形にすると、利用者が中継フィッシングに気づける

OAuth 2.0 のフロントチャネルと同じ問題がそのまま出る。

残る場所 対策
ブラウザの履歴 検証後すぐリダイレクトして URL からトークンを消す
アクセスログ /login/verify のクエリをログから落とす
Referer 検証ページから外部リンクを踏ませない
メール本文(永続) 10 分で失効させる。 これが最大の防御

メールボックスを持つ者がアカウントを持つ

Section titled “メールボックスを持つ者がアカウントを持つ”

これがマジックリンクの本質的な性質。 メールが乗っ取られたら、 そのメールで入れるサービスはすべて乗っ取られる。

  • メール側で2 要素認証(できればパスキー)を有効にしているかは利用者次第
  • 会社のメールなら管理者が読める
  • 退職・アカウント削除でアドレスが再利用されることがある

メールというチャネルは何を保証するか

Section titled “メールというチャネルは何を保証するか”

SPF / DKIM / DMARC を設定しても、メールの中身は保護されない。

仕様 何を認証するか 中身を守るか
RFC 7208 SPF 送信元 IP にそのドメインを名乗る権限があるか 守らない
RFC 6376 DKIM 署名により**ドメイン(d=)**を認証 改ざん検知はするが暗号化はしない
RFC 9989 DMARC From ヘッダと SPF / DKIM のドメインの整合 守らない

経路の保護は別の仕様の担当。

仕様 役割
RFC 8461 MTA-STS 受信側が「TLS を必須にする」ポリシーを公開する
RFC 8460 TLS-RPT TLS の失敗を報告してもらう
STARTTLS 日和見的な TLS。ダウングレードされうる

結論として、メールは「宛先に届くまでの経路を保証しない」チャネル。 だからこそ NIST が out-of-band 認証から排除している。

「NIST が SMS を禁止した」は不正確。 §3.1.3.3 の文言は:

Use of the PSTN for out-of-band verification is restricted as described in this section

そして §3.2.9 が、restricted な認証器を使う組織に 4 つすべてを SHALL で課す。

# 義務(§3.2.9)
1 “Offer subscribers at least one alternative authenticator that is not restricted
2 “Provide subscribers with meaningful notice regarding the restricted authenticator’s security risks”
3 “Address any additional risks to subscribers and RPs in its risk assessment
4 “Develop a migration plan for the possibility that the restricted authenticator will not be acceptable in the future”

「SMS しか用意していない」は 1 番に違反する。 そして 4 番は「いずれ使えなくなる前提で計画を持て」という要求で、 SMS を恒久的な手段として設計してはいけないことを意味する。

さらにリスク指標の確認が SHOULD で求められる:

Verifiers SHOULD consider risk indicators (e.g., device swap, SIM change, number porting, other abnormal behavior) before using the PSTN

SIM スワップと番号ポーティング

Section titled “SIM スワップと番号ポーティング”

電話番号は「所持」の証明として設計されていない。 攻撃者が通信事業者を騙して番号を自分の SIM に移せば、SMS はそちらへ届く。

flowchart TB
    A["攻撃者が事業者に連絡<br/>本人になりすます"]
    B["SIM 交換 / 番号ポーティング"]
    C["以後 SMS は攻撃者に届く"]
    D["パスワードリセット<br/>2 要素認証を突破"]

    A --> B --> C --> D

    style B fill:#7a2222,color:#fff
    style D fill:#7a2222,color:#fff

米国では FCC 23-952024-07-08 施行)が 事業者に義務を課した。

義務 内容
本人確認 “use secure methods to authenticate a customer … prior to effectuating SIM changes”
即時通知 immediately notify customers whenever a SIM change or port request is made”
ロック提供 顧客が “lock their accounts to block processing of SIM changes and number ports” できる仕組み

規則ができたこと自体が、この攻撃が現実に起きている証拠。 そして規則があるのは事業者側であって、サービス提供側は「番号は奪われうる」前提で設計するしかない。

NIST SP 800-63B-4 §3.2.5:

Authenticators that involve the manual entry of an authenticator output (e.g., out-of-band and OTP authenticators) SHALL NOT be considered phishing-resistant because the manual entry does not bind the authenticator output to the specific session

**理由が「手入力ではセッションに束縛されないから」**と書かれている点が重要。 SMS OTP もメール OTP も、この一文でフィッシング耐性を否定される。

マジックリンクは手入力しないので、この文が直接あてはまるとは書かれていない。 ただしセッションに束縛されないという性質は同じで、 偽サイトがリンクを踏ませて中継すれば通ってしまう(トークンを端末セッションに 紐づけていない限り)。耐性があるとは言えない、というのがこのページの立場。

耐性を持つのはオリジンに束縛される WebAuthn だけ。

方式 秘密の長さ チャネル NIST の分類 フィッシング耐性
マジックリンク 256 bit も可 メール out-of-band ではない 無い
メール OTP 6 桁 = 19.93 bit メール out-of-band ではない 無い
SMS OTP 6 桁 = 19.93 bit PSTN out-of-band(restricted 無い
TOTP 6 桁 = 19.93 bit なし(オフライン計算) Single-Factor OTP 無い
パスキー 公開鍵暗号 なし 暗号認証器 ある

TOTP との差はチャネルの有無。 TOTP は通信が要らないので、 チャネルを乗っ取る攻撃が原理的に存在しない。 SMS OTP が TOTP より弱いのはここで、エントロピーは同じ 19.93 bit。

flowchart TB
    Q1{"フィッシング耐性が<br/>要件か"}
    Q2{"利用者が認証アプリを<br/>使えるか"}
    Q3{"パスワードの<br/>代わりにするのか"}

    PK["パスキー"]
    TO["TOTP"]
    ML["マジックリンク<br/>+ 確認コード併用"]
    SM["SMS OTP<br/>最終手段 + 4 つの義務"]

    Q1 -->|"はい"| PK
    Q1 -->|"いいえ"| Q2
    Q2 -->|"はい"| TO
    Q2 -->|"いいえ"| Q3
    Q3 -->|"はい"| ML
    Q3 -->|"いいえ"| SM

    style PK fill:#1f5c33,color:#fff
    style TO fill:#4054b2,color:#fff
    style ML fill:#4054b2,color:#fff
    style SM fill:#7a2222,color:#fff

使ってよいケースと使ってはいけないケース

Section titled “使ってよいケースと使ってはいけないケース”

「安全か」ではなく「そのチャネルを認証に使ってよい条件が揃っているか」で判断する。

  • チャネルの乗っ取りが、そのまま全権掌握にならない設計になっている (高権限の操作には別の要素を要求する)
  • トークンは 32 バイト以上の乱数、コードは 6 桁以上を承認済み乱数生成器で作る
  • 有効期限を 10 分以内にしている
  • 単回使用にし、失敗時も含めて使い終わったら消している
  • アカウント単位・IP 単位・全体の 3 段のレート制限がある
  • マジックリンクなら GET でログインを完了させない(先読み対策)
  • SMS なら restricted の 4 つの義務(代替手段・通知・リスク評価・移行計画)を満たしている

ひとつでも欠けたら別の方式を検討する。 特に 1 つ目が欠けていると、 メールか電話番号を奪われた時点で何もかも終わる。

マジックリンクと SMS OTP が成立する 6 つの条件を 1 枚にまとめた復習カード

試験前に見返すならこの 1 枚。 コーラルで示した「単独の認証手段にしない」は、 他の 5 つを全部満たしても、これだけで設計が破綻する項目。

flowchart TB
    Q1{"扱う情報の重要度は<br/>高いか"}
    Q2{"チャネルを奪われても<br/>被害を限定できるか"}
    Q3{"他の要素と<br/>組み合わせるか"}

    OK["補助手段として使う<br/>+ 3 段のレート制限"]
    NG1["パスキー / TOTP を主にする<br/>これを単独手段にしない"]
    NG2["設計を先に直す<br/>高権限の操作を分離する"]
    NG3["単独手段にしない<br/>最低限パスワードと併用"]

    Q1 -->|"はい"| NG1
    Q1 -->|"いいえ"| Q2
    Q2 -->|"いいえ"| NG2
    Q2 -->|"はい"| Q3
    Q3 -->|"いいえ"| NG3
    Q3 -->|"はい"| OK

    style OK fill:#1f5c33,color:#fff
    style NG1 fill:#7a2222,color:#fff
    style NG2 fill:#7a2222,color:#fff
    style NG3 fill:#7a2222,color:#fff
ケース 成立する理由 一緒にやること
初回登録時のメール到達性の確認 もともと「そのアドレスに届くか」を確かめたいだけ。認証ではない 有効期限を短く。単回使用
パスワードリセットの入口 既に多くのサービスがこれを持っており、追加の弱点にならない リセット後に既存セッションを全部切る
低リスクなサービスのログイン(ニュースレター、社内 Wiki の閲覧) 奪われても被害が限定される GET で確定させない。確認コードを併用
パスキーが使えない利用者の受け皿 主手段はパスキーで、これは代替 SMS よりメールのほうがまだよい(4 つの義務が不要)
SMS を restricted として運用する(金融・公共) 4 つの義務を満たせば規範上は使える 代替手段を必ず並べる。移行計画を持つ
ケース できないこと
マジックリンクを唯一のログイン手段にする メールボックスが単一障害点になる。パスワードより弱くなりうる
メール OTP を「out-of-band 認証」として設計する NIST が Email SHALL NOT be used と明記。要件を満たさない
SMS を唯一の 2 要素目にする §3.2.9 の 1 番(代替手段の提供)に違反
フィッシング対策として導入する §3.2.5 が OTP / out-of-band を耐性から除外
GET /verify?token= で即ログインさせる メールゲートウェイの先読みでトークンが消費される
有効期限を数時間〜数日にする メールは永続する。期限だけが防御
アカウントの有無を応答で区別する メールアドレスの総当たりで会員名簿を作られる
トークンを平文で DB に保存する DB が漏れたら全アカウントに入れる
高権限の操作を同じ要素で通す チャネルを奪われた時点で全部通る
種別
マジックリンクを標準機能に持つ Auth0、Clerk、Supabase Auth、Firebase Authentication、Slack
SMS 配信 Twilio Verify、Amazon SNS、Vonage
認証基盤に組み込まれている Keycloak(メール OTP)、Okta
メールの到達性 SendGrid、Amazon SES、Postmark

Slack の「メールでログイン」が最も広く使われているマジックリンクで、 確認コード併用型(画面に出た文字列とメールの文字列を突き合わせる)を採用している。

import crypto from 'node:crypto';
const TTL_MS = 10 * 60 * 1000; // NIST の out-of-band は 10 分
app.post('/login/start', async (req, res) => {
const email = normalizeEmail(req.body.email);
// 1. レート制限は 3 段。アカウント単位だけでは横展開を止められない
await assertUnderLimit(`ip:${req.ip}`, 10, '1h');
await assertUnderLimit(`email:${email}`, 5, '1h');
await assertUnderLimit('global', 10_000, '1h');
const user = await findUserByEmail(email);
if (user) {
const token = crypto.randomBytes(32).toString('base64url'); // 256 bit
const hash = crypto.createHash('sha256').update(token).digest('hex');
// 2. 同じ利用者の未使用トークンは先に失効させる
await invalidateTokensFor(user.id);
await saveToken({
userId: user.id,
tokenHash: hash, // 平文は保存しない
expiresAt: Date.now() + TTL_MS,
usedAt: null,
startedSessionId: req.sessionID, // 同一端末の判定に使う
confirmCode: crypto.randomInt(0, 1_000_000).toString().padStart(6, '0'),
});
await sendMagicLinkMail(email, token); // メールにはトークンと確認コード
}
// 3. アカウントの有無で応答を変えない (OWASP)
// 「一貫したメッセージを返す」— 存在しなくてもここまで来る
res.json({ message: 'ログイン用のリンクを送信しました' });
});

押さえる点は 3 つ。

  1. レート制限を 3 段にする。 アカウント単位だけでは、 多数のアドレスに 1 回ずつ投げる攻撃が素通りする
  2. user が無くても同じ応答を返す。 OWASP の “Return a consistent message for both existent and non-existent accounts”
  3. startedSessionId を持っておく。 別端末で開かれたときに確認コードを要求できる
// GET は確認画面を出すだけ。トークンは消費しない
app.get('/login/verify', async (req, res) => {
const token = String(req.query.token ?? '');
const rec = await findValidToken(sha256(token)); // 期限内・未使用のみ
if (!rec) return res.status(400).render('login-failed');
const sameDevice = rec.startedSessionId === req.sessionID;
// 別端末なら確認コードの入力を要求する
res.render('login-confirm', { token, needCode: !sameDevice });
});
// POST で初めて確定する。メールゲートウェイの先読みはここまで来ない
app.post('/login/verify', async (req, res) => {
const token = String(req.body.token ?? '');
const hash = sha256(token);
await assertUnderLimit(`verify:${req.ip}`, 20, '1h');
// 取得と同時に使用済みにする。ここは atomic でなければならない
const rec = await consumeTokenAtomically(hash, Date.now());
if (!rec) return res.status(400).render('login-failed'); // 理由は返さない
if (rec.startedSessionId !== req.sessionID) {
const given = String(req.body.code ?? '');
const ok = given.length === rec.confirmCode.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(rec.confirmCode));
if (!ok) return res.status(400).render('login-failed');
}
// セッション固定攻撃の対策。ログイン成立時に必ず ID を振り直す
await regenerateSession(req);
req.session.userId = rec.userId;
// URL からトークンを消す
res.redirect(303, '/');
});

consumeTokenAtomically は atomic でなければならない。 「取得してから使用済みにする」と 2 手に分けると、同じリンクを同時に 2 回踏まれたときに 両方通るUPDATE ... WHERE used_at IS NULL AND expires_at > ? RETURNING * の形にする。

UPDATE login_tokens
SET used_at = now()
WHERE token_hash = $1
AND used_at IS NULL
AND expires_at > now()
RETURNING user_id, started_session_id, confirm_code;
app.post('/login/otp', async (req, res) => {
const code = String(req.body.code ?? '');
// 6 桁は 19.93 bit しかない。レート制限が唯一の防御
await assertUnderLimit(`otp:${req.session.pendingUserId}`, 5, '10m'); // アカウント単位
await assertUnderLimit(`otp-ip:${req.ip}`, 20, '1h'); // IP 単位
await assertUnderLimit('otp-global', 5_000, '1h'); // 全体
const rec = await consumeOtpAtomically(req.session.pendingUserId, sha256(code), Date.now());
if (!rec) {
await recordFailure(req.session.pendingUserId);
return res.status(400).json({ error: 'invalid' });
}
await regenerateSession(req);
req.session.userId = rec.userId;
res.json({ ok: true });
});
// 承認済み乱数生成器を使う。Math.random() は不可 (NIST §3.1.3.2)
function generateOtp() {
return crypto.randomInt(0, 1_000_000).toString().padStart(6, '0');
}

crypto.randomInt を使う。 Math.floor(Math.random() * 1e6) は “approved random bit generator” の要求を満たさない。

このカテゴリで唯一、認証が「届かないと成立しない」方式。

事象 起きること
メールが迷惑メールに入る 利用者が入れない。サポートコストに直結
SMS の国際配信が失敗する 国によって到達率が大きく違う
配信が 10 分以上遅れる 有効期限内に届かない。再送を促す導線が要る
送信コスト SMS は 1 通ごとに課金。総当たりが送信料の攻撃にもなる

再送ボタンにもレート制限をかける。 制限が無いと、 攻撃者が他人の電話番号に SMS を送りつけ続けられる(SMS 爆撃)。

扱い
トークン / OTP の平文 絶対に残さない。 10 分間は有効なので即座に悪用できる
/login/verify のクエリ文字列 アクセスログから落とす。 既定では丸ごと記録される
トークンのハッシュ 残してよい
メールアドレス / 電話番号 個人情報として扱う

リバースプロキシのアクセスログがクエリ文字列を記録する既定になっているのが 最も踏みやすい落とし穴。OAuth 2.0 の認可コードと同じ話。

アカウント回復の連鎖に注意する

Section titled “アカウント回復の連鎖に注意する”

「メールでリセットできる」が最終的な強度を決める。

パスキーを必須にした
→ 端末を失った利用者はメールでリセット
→ メールは実質パスワードだけで開く
→ 全体の強度はメールのパスワードと同じ

最も弱い回復経路が、そのアカウントの実際の強度。 高権限のアカウントほど、回復経路を人手の本人確認に寄せるか、 複数の認証器を必須にして回復自体を無くす

  • マジックリンクにも SMS OTP にも専用の RFC は無い。規範は NIST、指針は OWASP
  • NIST は Email SHALL NOT be used for out-of-band authentication と明記している
  • 理由は 3 つ — パスワードだけで開く・中継サーバーで読まれる・DNS 偽装で経路が変わる
  • out-of-band 認証器の定義には**「物理デバイス」**が含まれる
  • SMS は禁止ではなく restricted 4 つの義務が SHALL で課される
  • 4 つとは — 代替手段の提供・利用者への通知・リスク評価・移行計画
  • out-of-band の秘密は 6 桁以上、かつ approved random bit generator で生成
  • 有効期限は 10 分SHALL)。「24 時間が普通」に一次情報の根拠は無い
  • 単回使用が SHALL。失敗時も含めて使い終わったら消す
  • 6 桁は 19.93 bit。OWASP がセッション ID に求める 64 bit の 3 分の 1 以下
  • アカウント単位のレート制限だけでは横展開を止められない。IP 単位と全体も要る
  • トークンはハッシュして保存する。ただし bcrypt ではなく SHA-256 でよい (256 bit の乱数なので総当たりが成立しない)
  • GET でログインを完了させない。メールゲートウェイが先に開く
  • トークンの消費は atomic に。取得と更新を分けると同時に 2 回通る
  • アカウントの有無を応答で区別しない。応答時間も揃える
  • SPF / DKIM / DMARC はメールの中身を守らない。送信元ドメインの認証だけ
  • DMARC は RFC 9989(2026-05)。RFC 7489 は廃止された
  • 経路の保護は MTA-STS(RFC 8461)/ TLS-RPT(RFC 8460) の担当
  • フィッシング耐性は無い。理由は「手入力がセッションに束縛されないから」
  • SMS の弱点として NIST が挙げるのは device swap / SIM change / number porting
  • 最も弱い回復経路が、そのアカウントの実際の強度
NIST はメールで送るワンタイムコードを out-of-band 認証器と認めているか。

認めていない。 SP 800-63B-4 §3.1.3:

Email SHALL NOT be used for out-of-band authentication because it may be vulnerable to: Access using only a password; Interception in transit or at intermediate mail servers; Rerouting attacks, such as those caused by Domain Name System (DNS) spoofing

そもそも out-of-band 認証器の定義に “a physical device” が含まれており、 メールボックスはこれに当たらない。

メールが排除される理由を 3 つ挙げよ。
  1. “Access using only a password” — メールボックス自体がパスワードだけで開く。 多要素にしたつもりが 1 要素に戻る
  2. “Interception in transit or at intermediate mail servers” — 中継サーバーで読まれうる
  3. “Rerouting attacks, such as those caused by DNS spoofing” — MX を乗っ取れば配送先を変えられる

1 つ目が本質。 マジックリンクを唯一の手段にすると、 利用者のメールパスワードがそのままアカウントのパスワードになる。

NIST は SMS OTP を禁止したか。

禁止していない。restricted に指定した(§3.1.3.3)。

Use of the PSTN for out-of-band verification is restricted as described in this section

使い続けてよいが、§3.2.9 が 4 つすべてを SHALL で課す。

restricted な認証器を使う組織に課される 4 つの義務を挙げよ。
  1. restricted でない代替の認証器を最低 1 つ提供する
  2. リスクと代替手段の存在を利用者に意味のある形で通知する
  3. リスク評価に追加のリスクを織り込む
  4. 将来使えなくなる可能性に備えた移行計画を持ち、Digital Identity Acceptance Statement に含める

「SMS しか用意していない」は 1 番に違反する。 4 番は SMS を恒久的な手段として設計してはいけないことを意味する。

out-of-band の秘密の有効期限を、仕様の数字で答えよ。

10 分(NIST SP 800-63B-4 §3.1.3.2):

In all cases, the authentication SHALL be considered invalid unless completed within 10 minutes

「24 時間くらいが普通」という記述をよく見るが、NIST にも OWASP にもその数字は無い

6 桁コードのエントロピーと、それを守っているものは何か。

19.93 bitlog2(10^6))。OWASP がセッション ID に求める 64 bit の 3 分の 1 以下

守っているのはレート制限だけ。しかも「1 アカウント 100 回まで」は 多数のアカウントに少しずつ試す攻撃には効かない

規模 1 アカウント 100 回
1 万アカウント 期待 1 件成功
100 万アカウント 期待 100 件成功

アカウント単位・IP 単位・全体の 3 段が要る。

マジックリンクのトークンは bcrypt でハッシュすべきか。

不要。SHA-256 でよい。

bcrypt や Argon2 は「人間が選んだ低エントロピーの秘密」を総当たりから守るためのもの。 32 バイト(256 bit)の乱数には総当たりが成立しないので、 遅いハッシュは検証を遅くするだけ。

ただし平文で保存してはいけない。 DB が漏れたら全アカウントに入れる。 6 桁の OTP は 19.93 bit しかないので SHA-256 でも逆算されるが、 10 分で失効すること自体が保護になっている。

マジックリンクを `GET` で即ログインさせてはいけない理由は。

メールのセキュリティゲートウェイがリンクを先に開くから。 Microsoft の Safe Links は公式ドキュメントで “URL scanning and rewriting of inbound email messages in mail flow” と説明している。

配送時点でスキャンされると、単回使用のトークンが利用者のクリック前に消費される

対策は GET では確認画面を出すだけにし、POST でログインを確定すること。 UX の妥協ではなく機能要件。

マジックリンクが別の端末で開かれる問題への対策は。

確認コードを併用する。 開始した画面に数字を表示し、メールにも同じ数字を載せて、 利用者に一致を確認させる。

  • トークンだけで判定 → どの端末からでも入れる(漏れたら誰でも入れる)
  • 開始セッションに厳密に紐づける → 安全だが別端末では入れない
  • 確認コード併用 → 別端末でも安全に確定でき、中継フィッシングに気づける

Slack のメールログインがこの方式を採っている。

SPF / DKIM / DMARC はメールの中身を保護するか。

しない。送信元ドメインの認証だけ。

  • SPF(RFC 7208) — 送信元 IP にそのドメインを名乗る権限があるか
  • DKIM(RFC 6376) — 署名によりドメイン(d=)を認証。改ざん検知はするが暗号化はしない
  • DMARC(RFC 9989)From ヘッダと SPF / DKIM のドメインの整合を検証

経路の保護は MTA-STS(RFC 8461)TLS-RPT(RFC 8460) の担当。 STARTTLS は日和見的でダウングレードされうる。

DMARC の RFC 番号は。

RFC 9989(2026 年 5 月、Proposed Standard)。RFC 7489 と RFC 9091 を廃止した

「DMARC は RFC 7489」と書いてある資料は更新されていない。

マジックリンクにフィッシング耐性はあるか。

あるとは言えない。

NIST §3.2.5 は OTP と out-of-band 認証器を耐性から除外し、その理由を “the manual entry does not bind the authenticator output to the specific session” としている。マジックリンクは手入力しないのでこの文が直接あてはまるとは書かれていないが、 セッションに束縛されないという性質は同じで、偽サイトが中継すれば通る。

耐性を持つのは、オリジンに束縛される WebAuthn だけ。

SMS OTP と TOTP はどちらが強いか。理由も答えよ。

TOTP のほうが強い。エントロピーは同じ 19.93 bit だが、チャネルが無い。

TOTP はオフラインで計算するので、チャネルを乗っ取る攻撃が原理的に存在しない。 SMS は SIM スワップ・番号ポーティングで配送先を奪える。

NIST も TOTP を Single-Factor OTP として通常扱いにする一方、 PSTN は restricted に指定している。

SIM スワップに対して米国の規則は事業者に何を求めているか。

FCC 23-952024-07-08 施行):

  1. SIM 変更の前に “secure methods to authenticate a customer” で本人確認する
  2. SIM 変更・ポート要求があったら “immediately notify customers”
  3. 顧客が “lock their accounts” して SIM 変更と番号ポートを止められる仕組みを提供する

規則ができたこと自体が、この攻撃が現実に起きている証拠。 そして義務があるのは事業者側なので、サービス側は「番号は奪われうる」前提で設計する

トークンの消費処理で気をつけることは。

atomic に行う。 「取得してから使用済みにする」と 2 手に分けると、 同じリンクを同時に 2 回踏まれたときに両方通る

UPDATE login_tokens SET used_at = now()
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()
RETURNING user_id;

更新できた行が返ってきたときだけ成功とする。

「ログイン用リンクを送りました」の応答で気をつけることは。

アカウントの有無で応答を変えない。 OWASP:

Return a consistent message for both existent and non-existent accounts

さらに応答時間も揃える — “Ensure that the time taken for the user response message is uniform”。区別できると、メールアドレスの総当たりで会員名簿が作れる

パスキーを必須にしたサービスの実際の強度は何で決まるか。

最も弱い回復経路。

パスキー必須 → 端末を失ったらメールでリセット
→ メールはパスワードだけで開く
→ 全体の強度はメールのパスワードと同じ

高権限のアカウントほど、回復経路を人手の本人確認に寄せるか、 複数の認証器の登録を必須にして回復自体を無くす