ログイン機能の設計とは、「IDとパスワードを照合する」以外に決めておく値と振る舞い(パスワードの長さ、連続エラー時のロック、返す文言、セッションの扱い、パスワードリセットの流れ)を仕様書に書き出すことです。 画面は入力欄2つとボタン1つでも、仕様書に書く項目は30近くあります。

この記事は「仕様書に何を書くか」に絞り、決める項目の一覧表、アカウントロックとレート制限の選び方、パスワードリセットのシーケンス図、3つのテーブルのDDL、リセット用トークンをハッシュで保存して照合するPythonコードを載せます。値の根拠は NIST SP 800-63B-4 と OWASP Cheat Sheet Series の記載に合わせ、どちらにも具体値がないものは「例」と書き分けています。パスワードのハッシュ方式、Cookieの属性の詳細、外部IDとの連携、フレームワーク固有の設定はこの記事では扱いません。

確認環境(2026-10-05):Ubuntu 24.04、PostgreSQL 16.15、Python 3.11.15(同梱の SQLite 3.45.1)。Mermaid の図は @mermaid-js/mermaid-cli 12.0.0 と Chromium で描画できることを確かめています。指針は NIST SP 800-63B-4(2025年8月公開)と OWASP Cheat Sheet Series の3ページを同日に開いて確認しました。

この記事の結論

  • 仕様書には「値」「文言」「状態の変化」の3種類を書く。 値はパスワードの長さ・しきい値・期限、文言は画面に出すメッセージ、状態の変化はロックやセッションの切り替えです。
  • 文言は場面ごとに1種類にそろえる。 登録のないIDでも、パスワード違いでも、ロック中でも同じ文言を返します。
  • 連続エラーへの備えは、ID単位のロックとIP単位のレート制限を両方持つ。 片方だけでは拾えない入力パターンがあります(後半でSQLの結果を示します)。
  • リセット用トークンは乱数で作り、DBにはハッシュだけを保存し、1回使ったら無効にする。
  • ログインが通ったらセッションIDを再発行し、パスワードを変えたらそのユーザーのセッションを無効にする。

ログイン機能で決める仕様の早見表

「根拠」の列は、今回開いた指針に記載があるものだけ出典名を書いています。「例」とある値は指針に具体値がないため、この記事で置いた値です。システムの性質に合わせて決めてください。

分類 決める項目 仕様の書き方(値) 根拠
パスワード 最小の長さ パスワードだけで認証するなら15文字以上。多要素認証の一部として使うなら8文字以上 NIST 3.1.1.2
パスワード 最大の長さ 64文字以上を受け付ける NIST 3.1.1.2、OWASP 認証
パスワード 文字種の混在ルール 課さない(英大文字・数字・記号を必ず混ぜる、といった条件を付けない) NIST 3.1.1.2
パスワード 定期的な変更 求めない NIST 3.1.1.2
パスワード よく使われる値の照合 登録・変更時に、よく使われる値や漏えいが知られた値の一覧と照合して受け付けない NIST 3.1.1.2
パスワード 貼り付け・自動入力 パスワード管理ツールの自動入力を受け付ける。貼り付けも許可する NIST 3.1.1.2
文言 ログインできなかったとき 理由によらず1種類。「IDまたはパスワードが違います」 OWASP 認証
文言 リセット申請を受け付けたとき 登録の有無によらず1種類。「登録があれば、メールを送りました」 OWASP 認証、OWASP リセット
ロック 回数を数える単位 アカウント単位(接続元IPではなく、アカウントに回数を持たせる) OWASP 認証
ロック しきい値 連続5回(例) 例
ロック 回数を数える期間 直近15分(例) 例
ロック ロックする時間 固定なら15分(例)。OWASP は固定時間の例として10分、別案として1秒から始めて倍にしていく方式を挙げている OWASP 認証
ロック 連続エラーの上限 1つのアカウントで連続100回を超える前に、その認証手段を止める NIST 3.2.2
ロック 解除の方法 時間の経過で自動解除。パスワードリセットの完了でも解除(例) 例
レート制限 IP単位の上限 同じIPから15分に30回まで(例)。超えたら 429 を返す 例
セッション ログインが通ったとき セッションIDを再発行する OWASP セッション
セッション 無操作での期限 15〜30分(OWASP が低リスクのアプリの目安として挙げる範囲) OWASP セッション
セッション ログインからの期限 4〜8時間(OWASP が1日使い続けるアプリの目安として挙げる範囲) OWASP セッション
セッション Cookie の属性 Secure、HttpOnly、SameSite(Strict または Lax)を付ける OWASP セッション
リセット トークンの作り方 暗号用の乱数で作り、総当たりに耐える長さにする。この記事では32バイト(256ビット) OWASP リセット(長さは例)
リセット トークンの保存 平文で保存せず、ハッシュを保存する OWASP リセット
リセット 有効期限 30分(例) 例
リセット 使える回数 1回。使ったら無効にする OWASP リセット
リセット 再申請したとき 前のトークンを無効にし、最後の1本だけ有効(例) 例
リセット 申請の回数制限 アカウント単位で制限する。1時間に3回まで(例) OWASP リセット(回数は例)
リセット 申請でロックするか しない(リセットの申請を理由にアカウントをロックしない) OWASP リセット
リセット メールに載せるURL HTTPS。ホスト名はリクエストの Host ヘッダーから作らず、設定値から作る OWASP リセット
リセット 完了したとき 自動ではログインさせない。完了通知のメールを送る。既存のセッションを無効にするか利用者に尋ねる(この記事の例では常に無効にする) OWASP リセット
変更 ログイン中のパスワード変更 現在のパスワードをもう一度入力してもらう OWASP 認証
記録 ログイン試行の記録 日時・入力されたID・接続元IP・結果を残す。パスワードは残さない(例) 例

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「ログイン機能の設計項目一覧|パスワードリセットとアカウントロックの仕様」

NIST の値には強さの区別があります。上の表では、最小の長さ・混在ルールを課さない・定期変更を求めない・一覧との照合・連続100回の上限が「SHALL(必須)」、最大64文字以上と貼り付けの許可が「SHOULD(推奨)」です。NIST はセッションについても、再認証までの上限を保証レベルごとに挙げています(AAL1 は30日以内、AAL2 は24時間以内かつ無操作1時間以内が推奨)。

仕様書にそのまま貼れる項目表

表計算ソフトに貼れるタブ区切りです。「値」の列は、架空の備品予約システムを題材にした記入例です。

No  分類  項目  値   備考
1   パスワード   最小の長さ   15文字    多要素認証を入れる場合は8文字
2   パスワード   最大の長さ   128文字   64文字以上を受け付ける
3   パスワード   文字種の混在ルール   なし  
4   パスワード   定期的な変更  求めない    
5   パスワード   よく使われる値の照合  あり  登録時と変更時
6   パスワード   貼り付け・自動入力   許可  
7   文言  ログインできなかったとき    IDまたはパスワードが違います 理由によらず同じ
8   文言  リセット申請の受付   登録があれば、メールを送りました    登録の有無によらず同じ
9   ロック 数える単位   アカウント   
10  ロック しきい値    連続5回    
11  ロック 数える期間   15分 
12  ロック ロックする時間 15分 時間の経過で自動解除
13  ロック 解除の方法   自動解除、リセット完了 管理者による解除はなし
14  レート制限   IP単位の上限 15分に30回 超えたら429
15  セッション   ログインが通ったとき  セッションIDを再発行 
16  セッション   無操作での期限 30分 
17  セッション   ログインからの期限   8時間 
18  セッション   Cookieの属性   Secure、HttpOnly、SameSite=Lax    
19  リセット    トークンの長さ 32バイトの乱数    URLでは43文字
20  リセット    トークンの保存 SHA-256のハッシュ    平文は保存しない
21  リセット    有効期限    30分 
22  リセット    使える回数   1回  
23  リセット    再申請 前のトークンを無効にする    
24  リセット    申請の回数制限 1アカウント1時間に3回    
25  リセット    メールのURL 設定値のホスト名+HTTPS  
26  リセット    完了したとき  セッションを無効化、通知メール、ログイン画面へ 
27  変更  ログイン中のパスワード変更   現在のパスワードを再入力    
28  記録  ログイン試行  日時、入力ID、IP、結果   保存は90日

画面に返す文言とステータスの仕様

文言を場面ごとに1種類にそろえると、応答から「そのIDが登録されているか」を読み取れなくなります。仕様書では、内部の状況と利用者に見せる内容を分けて表にします。

場面 内部の状況 画面の文言 ステータス
ログイン IDの登録がない IDまたはパスワードが違います 401
ログイン パスワードが違う IDまたはパスワードが違います 401
ログイン ロック中 IDまたはパスワードが違います 401
ログイン アカウントが無効 IDまたはパスワードが違います 401
ログイン IP単位の上限を超えた しばらく待ってからもう一度お試しください 429
リセット申請 登録がある 登録があれば、メールを送りました 200
リセット申請 登録がない 登録があれば、メールを送りました 200
リセット申請 申請の回数制限を超えた 登録があれば、メールを送りました 200
リセット実行 トークンが違う・期限切れ・使用済み URLが無効です。もう一度申請してください 400

ロック中の利用者に何も知らせないと問い合わせが増えるため、ロックしたことは画面ではなく登録済みのメールアドレスへ通知すると、文言をそろえたまま本人には状況を伝えられます。文言だけでなく処理時間もそろえます。OWASP のリセットのページは、応答までの時間を一定にすることを挙げています。

ログイン処理の流れ

flowchart TD
    S([ログインIDとパスワードを受信]) --> R{IP単位・ID単位の<br/>回数制限を超えたか}
    R -- はい --> X1[429 を返す<br/>しばらく待つよう案内]
    R -- いいえ --> F[ユーザーを検索し<br/>登録がなくてもダミーのハッシュで照合]
    F --> L{登録があり<br/>ロック中か}
    L -- はい --> NG
    L -- いいえ --> P{パスワードが一致し<br/>状態が有効か}
    P -- いいえ --> C[連続回数を1増やす<br/>しきい値に達したら locked_until を設定<br/>login_attempts に記録]
    C --> NG[同じ文言を返す<br/>IDまたはパスワードが違います]
    P -- はい --> OK[連続回数を0に戻す<br/>セッションIDを再発行<br/>login_attempts に記録]
    OK --> E([ログイン後の画面へ])

登録のないIDのときもダミーのハッシュと照合するのは、登録の有無で処理時間に差が出ないようにするためです。

アカウントロックとレート制限の違いと選び方

どちらも「短い時間に何度も試される」ことへの備えですが、数える単位と、止まる範囲が違います。

比べる点 アカウントロック レート制限(ID単位) レート制限(IP単位) 待ち時間を延ばす方式
数える単位 アカウント 入力されたID 接続元IP アカウント
止まる範囲 そのアカウントへのログイン全部 そのIDへの試行 そのIPからの試行全部 そのアカウントの次の試行
得意な入力パターン 1つのIDに多数のパスワード 同左(登録のないIDも数えられる) 1つのIPから多数のID 1つのIDに多数のパスワード
拾えない入力パターン 多数のIDに1回ずつ 多数のIDに1回ずつ 多数のIPに分散した試行 多数のIDに1回ずつ
本人への影響 他人の操作で本人がログインできなくなることがある 同左(時間は短い) 同じIPを共有する利用者がまとめて待たされる 待ち時間が延びるだけで、入れなくなる時間は短い
必要なデータ users の連続回数とロック期限 login_attempts(ID、日時) login_attempts(IP、日時) users の連続回数と次に試せる時刻

OWASP の認証のページは、回数のカウンタを接続元IPではなくアカウントに持たせること、そしてロックの仕組みが他人のアカウントを止める手段として使われないよう配慮することの両方を挙げています。NIST も、正しい利用者が締め出されにくくする追加の方法として、ボット判定の課題を出すこと、連続回数が上限に近づくほど待ち時間を延ばすこと(例として30秒から1時間まで)、リスクに応じた判定を使うことを挙げています。

条件 選ぶもの
まず最低限を入れたい アカウント単位の一時ロック(時間で自動解除)+ IP単位のレート制限
他人の操作で本人が入れなくなる時間を短くしたい 固定のロックではなく、待ち時間を延ばす方式にする
ログインIDが推測しやすい(社員番号、連番など) IP単位のレート制限を必ず併用し、しきい値を越えたらボット判定の課題を出す
同じIPを多くの利用者が共有する(社内ネットワークなど) IP単位の上限をゆるめにし、アカウント単位を主にする
登録のないIDへの試行も数えたい users の列ではなく login_attempts の入力IDで数える
管理者しか解除できないロックにしたい 問い合わせ窓口と本人確認の手順を合わせて仕様に書く。時間で解除する方式より運用の手間が増える

待ち時間を倍にしていくと何回目でどのくらいか

OWASP が別案として挙げる「1秒から始めて、認証が通らないたびに倍にする」方式を Python で計算すると、次のようになりました。

連続回数 次に試せるまでの待ち時間 そこまでの合計
5回目 16秒 31秒
10回目 512秒(約8.5分) 約17分
12回目 2,048秒(約34分) 約68分
13回目 4,096秒(約68分) 約137分

10回目までは合計しても17分ほどで、入力ミスを数回した本人はほとんど待たされません。13回目で1回の待ち時間が1時間を超えるので、仕様には「待ち時間の上限(例:1時間)」も合わせて書きます。

パスワードリセットのシーケンス図

sequenceDiagram
    autonumber
    actor U as 利用者
    participant W as Webアプリ
    participant D as データベース
    participant M as メール送信

    U->>W: リセットを申請(ログインIDを入力)
    W->>D: 申請回数を確認(ID単位・IP単位)
    W->>D: ログインIDでユーザーを検索
    alt 登録がある
        W->>W: 乱数トークンを生成(32バイト)
        W->>D: 未使用トークンを無効化し、ハッシュと期限を保存
        W->>M: トークン入りURLを送信
    else 登録がない
        W->>W: 何も送らない(処理時間はそろえる)
    end
    W-->>U: 同じ文言を表示「登録があれば、メールを送りました」

    U->>W: メールのURLを開く(トークン付き)
    W->>D: ハッシュで検索(未使用かつ期限内か)
    alt 有効
        W-->>U: 新しいパスワードの入力画面
        U->>W: 新しいパスワードを送信(トークン付き)
        W->>D: トークンを使用済みに更新(1件更新できたときだけ続行)
        W->>D: パスワードのハッシュを更新、連続回数とロックを解除
        W->>D: そのユーザーのセッションを無効化
        W->>M: 変更完了の通知を送信
        W-->>U: 完了画面(自動ログインはせず、ログイン画面へ)
    else 無効・期限切れ・使用済み
        W-->>U: 同じ文言を表示「URLが無効です。もう一度申請してください」
    end

図の中で仕様として書き落としやすいのは次の4点です。

番号 決めること 理由
2 申請の回数制限はアカウント単位で数え、申請を理由にアカウントはロックしない 他人が申請を繰り返しても、本人のログインは止まらない
7 登録の有無によらず同じ文言・同じくらいの処理時間で返す 応答から登録の有無が分からない
12 トークンを「使用済み」にする更新が1件できたときだけ、パスワードを更新する 同じURLを2回送られても、2回目は進まない
14〜16 セッションの無効化、完了通知、自動ログインなし 本人以外が変更した場合に、本人がメールで気づける

URLを開いた画面(番号9)には、外部サイトへトークン入りのURLが伝わらないよう、Referrer-Policy に no-referrer を付けることも OWASP のリセットのページに挙げられています。

users・login_attempts・password_reset_tokens のDDL

PostgreSQL 16 で実行したDDLです。

CREATE TABLE users (
  id                  bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  login_id            text        NOT NULL,              -- メールアドレスなど。小文字にそろえて保存
  password_hash       text        NOT NULL,              -- Argon2id などの出力をそのまま保存
  status              text        NOT NULL DEFAULT 'active'
                      CHECK (status IN ('active', 'disabled')),
  failed_count        integer     NOT NULL DEFAULT 0,    -- 連続して認証が通らなかった回数
  locked_until        timestamptz,                       -- この時刻まで一時ロック。NULL はロックなし
  password_changed_at timestamptz NOT NULL DEFAULT now(),
  created_at          timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT users_login_id_key UNIQUE (login_id)
);

CREATE TABLE login_attempts (
  id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  login_id     text        NOT NULL,                     -- 入力された値。登録のないIDも記録する
  user_id      bigint      REFERENCES users(id),         -- 登録のないIDのときは NULL
  ip_address   inet        NOT NULL,
  succeeded    boolean     NOT NULL,
  attempted_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX login_attempts_login_id_idx ON login_attempts (login_id, attempted_at);
CREATE INDEX login_attempts_ip_idx       ON login_attempts (ip_address, attempted_at);

CREATE TABLE password_reset_tokens (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id     bigint      NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash  char(64)    NOT NULL,                      -- トークンの SHA-256(16進64文字)。平文は保存しない
  expires_at  timestamptz NOT NULL,
  used_at     timestamptz,                               -- 使った時刻。NULL は未使用
  requested_ip inet,
  created_at  timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT password_reset_tokens_hash_key UNIQUE (token_hash)
);
CREATE INDEX password_reset_tokens_user_idx ON password_reset_tokens (user_id);
テーブル 役割 設計のポイント
users いまの状態(連続回数、ロック期限) ロックを「フラグ」ではなく「期限の時刻」で持つと、解除のバッチが要らない
login_attempts 試行の記録(増え続ける) 登録のないIDも残せるよう、user_id は NULL を許す。保存期間を決めて、期間を過ぎた行から消す
password_reset_tokens 発行したトークン ハッシュに一意制約を付け、検索と重複防止を兼ねる。used_at で1回限りを表す

ロックの判定と解除をSQLで確かめる

連続4回の状態から、5回目で認証が通らなかったときの更新です。回数を増やす処理としきい値の判定を1文にまとめると、同時に2つのリクエストが来ても回数がずれません。

UPDATE users
SET failed_count = failed_count + 1,
    locked_until = CASE WHEN failed_count + 1 >= 5
                        THEN now() + interval '15 minutes' ELSE locked_until END
WHERE id = 1
RETURNING id, failed_count, locked_until IS NOT NULL AS locked;
 id | failed_count | locked 
----+--------------+--------
  1 |            5 | t
(1 row)

ログイン時のロック判定と、ログインが通ったときの解除です。

SELECT id, (locked_until IS NOT NULL AND locked_until > now()) AS is_locked FROM users ORDER BY id;

UPDATE users SET failed_count = 0, locked_until = NULL WHERE id = 1
RETURNING id, failed_count, locked_until;
 id | is_locked 
----+-----------
  1 | t
  2 | f
(2 rows)

 id | failed_count | locked_until 
----+--------------+--------------
  1 |            0 | 
(1 row)

ID単位とIP単位で数えると、拾えるものが違う

login_attempts に、2種類の入力パターンを入れて集計しました。1つは 203.0.113.10 から sato@example.com へ5回、もう1つは 198.51.100.7 から30個の別々のIDへ1回ずつです。

-- ID単位:直近15分に5回以上
SELECT login_id, count(*) AS failures
FROM login_attempts
WHERE succeeded = false AND attempted_at > now() - interval '15 minutes'
GROUP BY login_id HAVING count(*) >= 5;

-- IP単位
SELECT ip_address, count(*) AS failures, count(DISTINCT login_id) AS ids
FROM login_attempts
WHERE succeeded = false AND attempted_at > now() - interval '15 minutes'
GROUP BY ip_address ORDER BY failures DESC;
     login_id     | failures 
------------------+----------
 sato@example.com |        5
(1 row)

  ip_address  | failures | ids 
--------------+----------+-----
 198.51.100.7 |       30 |  30
 203.0.113.10 |        5 |   1
(2 rows)

ID単位の集計には 198.51.100.7 の30回が1件も出てきません。 どのIDも1回ずつなので、しきい値に届かないためです。IP単位で数えると、30回・30種類のIDとして現れます。アカウントロックとIP単位のレート制限を両方持つ理由がここにあります。

トークンをハッシュ化して保存し照合するPythonコード

標準ライブラリだけで動く完成形です。DBは説明用にメモリ上の SQLite を使っています。

"""パスワードリセット用トークンの発行と照合(標準ライブラリのみ)
実行: python3 reset_token.py
"""
import hashlib
import secrets
import sqlite3
from datetime import datetime, timedelta, timezone

TOKEN_TTL = timedelta(minutes=30)   # 有効期限(例)

db = sqlite3.connect(":memory:")
db.execute("""CREATE TABLE password_reset_tokens (
  id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL,
  token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, used_at TEXT)""")


def sha256_hex(token: str) -> str:
    return hashlib.sha256(token.encode("ascii")).hexdigest()


def issue(user_id: int, now: datetime) -> str:
    """トークンを発行する。DBにはハッシュだけを保存し、平文はメールに載せる分だけ返す。"""
    # 同じユーザーの未使用トークンは無効にする(最後に発行した1本だけ有効)
    db.execute("UPDATE password_reset_tokens SET used_at = ? "
               "WHERE user_id = ? AND used_at IS NULL", (now.isoformat(), user_id))
    token = secrets.token_urlsafe(32)          # 32バイト = 256ビットの乱数
    db.execute("INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) "
               "VALUES (?, ?, ?)", (user_id, sha256_hex(token), (now + TOKEN_TTL).isoformat()))
    return token


def consume(token: str, now: datetime) -> int | None:
    """トークンを照合し、有効なら使用済みにして user_id を返す。無効なら None。"""
    cur = db.execute(
        "UPDATE password_reset_tokens SET used_at = ? "
        "WHERE token_hash = ? AND used_at IS NULL AND expires_at > ? "
        "RETURNING user_id",
        (now.isoformat(), sha256_hex(token), now.isoformat()))
    row = cur.fetchone()
    return row[0] if row else None


t0 = datetime(2026, 10, 5, 10, 0, tzinfo=timezone.utc)

token = issue(user_id=1, now=t0)
print("トークンの長さ        :", len(token), "文字")
stored = db.execute("SELECT token_hash FROM password_reset_tokens").fetchone()[0]
print("DBに保存した値の長さ  :", len(stored), "文字(SHA-256の16進)")
print("DBの値とトークンは別物:", stored != token)

print("1) 5分後に正しいトークン :", consume(token, t0 + timedelta(minutes=5)))
print("2) 同じトークンをもう一度:", consume(token, t0 + timedelta(minutes=6)))
print("3) でたらめなトークン    :", consume("x" * 43, t0 + timedelta(minutes=6)))

token2 = issue(user_id=1, now=t0)
print("4) 31分後(期限切れ)    :", consume(token2, t0 + timedelta(minutes=31)))

token3 = issue(user_id=1, now=t0)
token4 = issue(user_id=1, now=t0 + timedelta(minutes=1))   # 再発行
print("5) 再発行前のトークン    :", consume(token3, t0 + timedelta(minutes=2)))
print("6) 再発行後のトークン    :", consume(token4, t0 + timedelta(minutes=2)))

実行結果:

トークンの長さ        : 43 文字
DBに保存した値の長さ  : 64 文字(SHA-256の16進)
DBの値とトークンは別物: True
1) 5分後に正しいトークン : 1
2) 同じトークンをもう一度: None
3) でたらめなトークン    : None
4) 31分後(期限切れ)    : None
5) 再発行前のトークン    : None
6) 再発行後のトークン    : 1

コードの要点は3つです。

要点 コードの箇所 ねらい
乱数は secrets で作る secrets.token_urlsafe(32) 暗号用の乱数源を使う。URLにそのまま載せられる文字だけになる
DBにはハッシュだけ sha256_hex(token) を保存 DBの中身を見られても、そのままではURLを組み立てられない
照合と使用済み化を1文で UPDATE … WHERE used_at IS NULL AND expires_at > 現在 RETURNING 「確認してから更新」の2段階にしないので、同時に2回送られても通るのは1回

なぜ SHA-256 でよいのか

OWASP のリセットのページは、トークンを「安全な方法で保存する」こととし、パスワードの保存と同じ考え方に従うよう書いています。この記事のコードが SHA-256 を使うのは、次の2つの理由からです。

  • トークンは256ビットの乱数で、人が決めるパスワードと違って推測の手がかりがありません。
  • ハッシュ値で行を検索するため、同じ入力から毎回同じ値が出る方式が必要です。ソルト付きの方式は毎回値が変わり、一意制約と索引で探せません。

6〜12桁の数字のような短いコードを使う場合は事情が変わります。取りうる値が少ないので、ハッシュの方式よりも入力できる回数の上限(例:5回まで)と短い有効期限を仕様に書くことが効きます。

PostgreSQL での1回限りの更新

同じ考え方を PostgreSQL で実行した結果です。1回目は user_id が返り、2回目は0行です。

UPDATE password_reset_tokens SET used_at = now()
WHERE token_hash = encode(sha256('dummy-token-for-sql-check'::bytea), 'hex')
  AND used_at IS NULL AND expires_at > now()
RETURNING user_id;
-- 1回目
 user_id 
---------
       1
(1 row)

UPDATE 1

-- 2回目
 user_id 
---------
(0 rows)

UPDATE 0

アプリ側は「返ってきた行が1件のときだけパスワードを更新する」と決めておき、トークンの更新とパスワードの更新を同じトランザクションに入れます。

確かめて分かったこと

  • 32バイトのトークンはURLでは43文字、保存するハッシュは64文字になる。 メール本文の折り返しや列の長さを決めるときの目安になります。
  • ID単位の集計は、30個のIDを1回ずつ試す入力を1件も拾わなかった。 IP単位の集計では30回として現れました。
  • 1回限りの扱いは UPDATE … RETURNING の1文で書ける。 PostgreSQL 16 でも SQLite 3.45 でも、2回目は0行でした。
  • 再申請で前のトークンを無効にする処理は、発行の直前に UPDATE を1つ足すだけで済む。 実行結果の 5) が None、6) が 1 になっています。
  • 待ち時間を倍にする方式は、10回目までの合計が約17分、13回目で1回の待ちが1時間を超える。 上限の値を合わせて決める必要があります。
  • NIST が数値で定めているのは「連続100回を超えない」という上限で、5回や10回といったしきい値は今回開いた NIST・OWASP のページには具体値がない。 しきい値は、利用者の入力ミスの多さと守りたい情報の重さから自分たちで決める値です。

よくある質問

Q. アカウントロックは何回からにすればよいですか? A. 指針が数値で定めているのは上限だけで、NIST SP 800-63B-4 は1つのアカウントで連続100回を超えないよう求めています。その範囲で、入力ミスをした本人が困らない回数(例:5〜10回)を選び、時間の経過で自動解除する形にすると運用しやすくなります。

Q. アカウントロックとレート制限の違いは何ですか? A. 数える単位と止まる範囲が違います。アカウントロックは1つのアカウントへのログイン全部が止まり、レート制限は一定時間あたりの回数を超えた分だけが待たされます。1つのIDへの連続入力にはロック、1つのIPから多数のIDへの入力にはIP単位のレート制限が向いています。

Q. パスワードリセットのURLの有効期限は何分にすればよいですか? A. 今回開いた OWASP のページに分数の指定はなく、この記事では例として30分にしています。メールが届くまでの時間と、届いてから操作するまでの時間を足して決め、期限切れのときは申請からやり直せる画面にします。

Q. 「IDまたはパスワードが違います」とまとめると、利用者が困りませんか? A. ログイン画面にリセットへの入口を置けば困りにくくなります。IDを思い出せない人もパスワードを思い出せない人も、リセットの申請から同じ流れで進めます。ロックしたときは、画面ではなくメールで本人に知らせます。

Q. リセットが完了したら、そのままログインさせてもよいですか? A. OWASP のリセットのページは、自動ではログインさせないことを挙げています。完了後はログイン画面へ進めて、新しいパスワードで通常のログインをしてもらうと、セッションの発行経路が1つにまとまります。

まとめ

  • 仕様書には、値(長さ・しきい値・期限)、文言、状態の変化(ロック・セッション)の3種類を書く。
  • パスワードは単独で使うなら15文字以上、64文字以上を受け付け、混在ルールと定期変更は課さない(NIST SP 800-63B-4)。
  • 文言は場面ごとに1種類。 登録の有無やロック中かどうかを画面から読み取れないようにする。
  • アカウント単位のロックとIP単位のレート制限は拾える入力が違うので、両方持つ。
  • リセット用トークンは32バイトの乱数、DBにはハッシュ、期限つき、1回限り、再申請で前のものを無効に。
  • トークンの照合と使用済み化は UPDATE … RETURNING の1文にまとめる。
  • ログインが通ったらセッションIDを再発行し、リセットが完了したらセッションを無効にして通知メールを送る。

参考・出典

  • NIST SP 800-63B-4 Digital Identity Guidelines: Authentication and Authenticator Management(2025年8月)3.1.1.2 Password Verifiers、3.2.2 Rate Limiting — https://pages.nist.gov/800-63-4/sp800-63b.html
  • OWASP Cheat Sheet Series: Authentication Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
  • OWASP Cheat Sheet Series: Forgot Password Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html
  • OWASP Cheat Sheet Series: Session Management Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html

(いずれも 2026-10-05 に確認)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「ログイン機能の設計項目一覧|パスワードリセットとアカウントロックの仕様」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?