UUIDv7 と連番、どちらを ID に使うべきか。2026年10月17日、X 上で「IDをUUIDで管理すれば、脆弱性があっても盗まれにくいと考えるのがセキュリティの発想だが、連番に比べて性能が著しく落ちると聞いた。現実は理想通りにいかない」という趣旨の声がありました。この見立ては半分だけ当たっています。推測されにくい ID は認可の代わりにならず、性能の差は「UUID だから」ではなく「乱数の並びで主キーの B-tree に入れるから」生じます。SQLite と PostgreSQL 16 で 100万行を挿入して確かめました。
TWO HALVESよくある理解と実態
推測されにくさと認可は別の話、遅さは乱数の並びの話
よくある理解
実態
よくある理解: UUIDにすれば脆弱性があっても盗まれにくい
実態: IDは共有リンクやログで漏れる。漏れたら止めるのは認可チェックだけ
よくある理解: UUIDは連番より著しく遅い
実態: 遅いのは乱数のUUIDv4を主キーのB-treeに入れたとき
よくある理解: UUIDv7にすれば両方解決する
実態: 挿入の並びは連番に近づくが、作成時刻が読める。認可は別に要る
30秒でわかる
- ・OWASP API Security Top 10 2023 の1位(BOLA)は、ID が連番でも UUID でも起きると明記している
- ・ID は共有リンク・ログ・Referer・他の API の応答から漏れる。止めるのは所有者の確認だけ
- ・SQLite で 100万行を挿入すると、UUIDv4 の主キーは連番の約18倍(19.05秒対1.05秒)、UUIDv7 は約2倍(2.02秒)だった
- ・PostgreSQL 16 では差は小さい(中央値 3.74秒対2.25秒)が、UUIDv4 の索引は葉の充填率が 72% に下がった
- ・UUIDv7 は作成時刻が読める。自作するなら同じミリ秒の中でも増える作りにする
この連載は、実際の事故と開発者の議論から Web アプリの守り方を1回1テーマで整理しています。セキュリティ分野の記事の全体像はサプライチェーン攻撃とは|手口・防御ツール比較・npm 12の新機構まで実践解説から辿れます。
10月13日には NICT が、公開されている Android アプリを解析して API の接続先やパラメータを割り出し、裏のサーバを直接攻撃する「A2B(App to Backend)攻撃」を、国内で相次ぐ情報漏えいの原因の一つとして注意喚起しました。アプリの通信を見れば、ID がどんな形で API に渡っているかはすぐ分かります。NICT が重点的な点検の筆頭に挙げるのは認可不備(IDOR/BOLA)で、ID を推測しにくくしてもこの点検は省けません。本記事の結論も同じ向きです。
IDをUUIDにしても認可漏れ(IDOR)を防げない理由
OWASP は「IDの型に関係なく起きる」と書いている
OWASP API Security Top 10 2023 の1位は BOLA(Broken Object Level Authorization)です。Web アプリの文脈では IDOR(Insecure Direct Object Reference)とも呼ばれます。OWASP は攻撃の入口を「リクエストで送られるオブジェクトの ID を書き換えること」とし、ID は連番の整数、UUID、一般的な文字列のどれでもあり得る、型が何であれリクエストの中で簡単に見つかる、と書いています。
UUID の規格である RFC 9562 も、セキュリティの節の冒頭で「実装は UUID が推測しにくいと仮定すべきでない(SHOULD NOT)」とし、持っているだけでアクセスできる鍵(capability)として使ってはならない(MUST NOT)と定めています。
IDが漏れる経路は推測より多い
連番は id+1 で隣が開けるので、推測による列挙は確かに難しくなります。それでも、ID は次の経路から第三者に渡ります。
・共有リンク、招待 URL、メールの本文
・アクセスログ、エラー監視、サポート窓口に貼られたスクリーンショット
・外部サイトへ遷移したときの Referer ヘッダー
・一覧 API やコメント、通知の応答に含まれる他人のオブジェクトの ID
・ブラウザの履歴や拡張機能
共有リンクが検索エンジンに拾われた例として、Claude の共有会話が Google 検索に出た件があります。推測できない URL でも、外に出れば誰でも開けます。OWASP の IDOR チートシートも、GUID のような複雑な ID は推測をほぼ不可能にするが、それでも認可チェックは不可欠で、攻撃者が他人のオブジェクトの URL を手に入れた場合も拒否すべきだとしています。
UUID は「推測」を難しくするだけで、「知られたID」には何もしない。 認可チェックが抜けていれば、漏れた UUID 1つで他人のデータが開きます。SiYuan の CVE-2026-66012 のように認可そのものが抜けた事例では、ID の形は守りになりません。
UUIDv4の主キーが遅くなる仕組みとUUIDv7
主キーのB-treeに乱数を入れるとどうなるか
RDB の主キーはふつう B-tree の索引です。MySQL の InnoDB では主キーがクラスタ化インデックスになり、行のデータそのものが主キーの順に並びます。さらにセカンダリインデックスの各レコードが主キーの列を持つので、主キーが長いと他の索引も大きくなります(MySQL リファレンスマニュアル)。
連番は常に B-tree の右端に追記されます。乱数の UUIDv4 は毎回ばらばらの位置に入るため、挿入のたびに別のページを読み、満杯のページを割る(ページ分割)ことになります。索引がメモリに収まらない規模になると、この「読む場所が毎回違う」ことが効いてきます。RFC 9562 も、UUIDv4 のような時刻順でない UUID はデータベースの索引の局所性が悪く、B-tree への影響は「劇的になり得る」と書いています。
UUIDv7は先頭が時刻なので右端に寄る
UUIDv7 は先頭 48 ビットにミリ秒単位の Unix 時刻を置き、残りを乱数で埋める形式です(RFC 9562 §5.7)。後から作った ID ほど値が大きくなるので、挿入は連番と同じく右端に寄ります。RFC は、時刻順の UUID と乱数の UUID の差が「1桁以上になり得る」としています。
ただし同じミリ秒の中では乱数の順になります。RFC 9562 §6.2 はこれを補うため、ミリ秒未満の時刻やカウンタで単調に増やす方法を示しています。影響を受けるのは大量の行を短時間に入れる処理で、後述の実測でこの差がはっきり出ました。
実測:連番・UUIDv4・UUIDv7を100万行挿入
計測の条件
4コアの Intel Xeon(2.10GHz)、メモリ 15GiB の Linux VM で、Python 3.13.16 の sqlite3(SQLite 3.45.1)と PostgreSQL 16.15 を使いました。どちらも 64 バイトの文字列を持つ行を 100万行入れ、ID は事前に生成して計測に含めていません。Python 3.13 には uuid.uuid7() が無い(3.14 で追加)ため、UUIDv7 は RFC 9562 §5.7 に沿って自作しました。SQLite では UUID の表を WITHOUT ROWID にし、主キーの B-tree に行が並ぶ形(クラスタ化)にしています。
# bench.py — python3 bench.py 1000000
import os, sys, time, uuid, sqlite3
N = int(sys.argv[1])
def uuid7(): # RFC 9562 §5.7: 48bitのミリ秒 | ver 7 | 乱数(var は 10)
ms = time.time_ns() // 1_000_000
r = int.from_bytes(os.urandom(10), "big")
v = (ms << 80) | (7 << 76) | (((r >> 64) & 0xFFF) << 64) | (2 << 62) | (r & (2**62 - 1))
return v.to_bytes(16, "big")
cases = {
"int": ("CREATE TABLE t(id INTEGER PRIMARY KEY, p TEXT)", None),
"uuid4": ("CREATE TABLE t(id BLOB PRIMARY KEY, p TEXT) WITHOUT ROWID", lambda: uuid.uuid4().bytes),
"uuid7": ("CREATE TABLE t(id BLOB PRIMARY KEY, p TEXT) WITHOUT ROWID", uuid7),
}
for name, (ddl, gen) in cases.items():
rows = [(gen() if gen else None, "x" * 64) for _ in range(N)] # IDの生成は計測に含めない
path = f"bench_{name}.db"
if os.path.exists(path): os.remove(path)
con = sqlite3.connect(path, isolation_level=None)
con.execute("PRAGMA journal_mode=WAL"); con.execute("PRAGMA synchronous=NORMAL"); con.execute(ddl)
t0 = time.perf_counter()
for s in range(0, N, 10_000):
con.execute("BEGIN"); con.executemany("INSERT INTO t VALUES (?, ?)", rows[s:s + 10_000]); con.execute("COMMIT")
con.execute("PRAGMA wal_checkpoint(TRUNCATE)")
print(f"{name}: {time.perf_counter() - t0:.2f}s {os.path.getsize(path) / 1e6:.1f}MB")
con.close()
このスクリプトそのものを 1回実行した結果は int 1.11秒、uuid4 21.50秒、uuid7 2.12秒でした。下の表は、同じ処理に「ミリ秒内も単調増加の UUIDv7」(生成した UUIDv7 を昇順に並べ替えて代用)と「連番の主キー+UUIDv4 の UNIQUE 列」を足した計測の 2回の平均です。
INSERT 1M ROWS100万行の挿入時間
SQLiteではUUIDv4の主キーだけが桁違いに遅い
SQLiteとPostgreSQLの結果
| 主キーの設計 | SQLite 挿入(2回) | SQLite ファイル | PostgreSQL 挿入(3回の中央値) | PG の UUID 索引(葉の充填率) | PG の WAL |
|---|---|---|---|---|---|
| 連番 | 0.95・1.14秒 | 74.6MB | 2.25秒 | 22.5MB(90.06%) | 197.8MB |
| UUIDv4 | 19.27・18.83秒 | 100.2MB | 3.74秒 | 39.5MB(72.01%) | 226.2MB |
| UUIDv7(ミリ秒内は乱数) | 2.02・2.02秒 | 99.7MB | 2.36秒 | 42.0MB(67.74%) | 218.0MB |
| UUIDv7(ミリ秒内も単調) | 1.64・1.66秒 | 100.0MB | 2.19秒 | 31.6MB(90.03%) | 211.9MB |
| 連番+外部 UUIDv4 | 12.10・12.23秒 | 121.1MB | 4.53秒 | 39.5MB(72.01%) | 303.7MB |
PostgreSQL は、乱数で埋めた UNLOGGED の作業表から INSERT ... SELECT を1文で流し、索引の大きさと充填率は pgstattuple 拡張の pgstatindex() で測りました。連番の索引は 8 バイトの bigint なので、16 バイトの UUID と大きさを直接は比べられません。
読み取れること
・SQLite では UUIDv4 だけが桁違いに遅い。 ファイルサイズは UUIDv7 とほぼ同じなので、差はサイズではなくページの読み方から来ています。SQLite の既定のページキャッシュ(PRAGMA cache_size が -2000=約2MB)は 100MB の DB より小さく、乱数の挿入は毎回キャッシュの外のページに触ります
・PostgreSQL では差が小さい。 39.5MB の索引が既定の shared_buffers(128MB)に収まるためと考えられます。shared_buffers を 16MB に絞ると UUIDv4 は 4.95・5.01秒、連番は 2.37・2.50秒で、差は約1.6倍から約2倍に広がりました
・ミリ秒内が乱数の UUIDv7 は、索引が UUIDv4 より膨らんだ。 同じミリ秒に入る大量の値が右端付近のページを半分ずつに割り、時刻が進むと二度と埋まらないためと考えられます。昇順に揃えると充填率は 90.03% で連番と並びました
・内部連番+外部 UUIDv4 は、PostgreSQL で最も遅く WAL も最大。 乱数の B-tree を1本持つことに変わりはないためです
限界もあります。1台の VM で、同時接続なし、データ量は索引がメモリに収まる 100万行です。本番の DB で索引がメモリを大きく超えたときの差や、MySQL(InnoDB)での挙動は測っていません(未検証)。SQLite の結果は本番の RDB の性能を表すものではなく、「キャッシュに収まらない乱数挿入」の傾向を見るためのものです。手元の PostgreSQL で索引の詰まり具合を確かめるなら、次の2行で足ります(t_pkey は確かめたい索引名)。
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT avg_leaf_density, leaf_pages FROM pgstatindex('t_pkey');
UUIDv7の注意点:作成時刻が読める
先頭48ビットはそのまま日時になる
UUIDv7 の先頭は時刻なので、ID を見た人は作成日時を知れます。PostgreSQL 17 で加わった uuid_extract_timestamp() は、v1 と v7 の UUID から時刻を取り出す関数です。RFC 9562 は、埋め込まれた時刻の攻撃面は「とても小さい」としつつ、作成順を示すことを認め、セキュリティ上の操作に UUID を使うなら UUIDv4 を使うべき(SHOULD)と書いています。会員 ID から登録日時、注文 ID から購入時刻が分かってよいかは、サービスごとに判断が要ります。
UUIDv7のタイムライン
| 時期 | 出来事 | 確認元 |
|---|---|---|
| 2024年5月 | RFC 9562 公表。RFC 4122 を置き換え、UUIDv7 を定義 | RFC Editor の索引 |
| 2024-09-26 | PostgreSQL 17 リリース。uuid_extract_timestamp() を追加 |
PostgreSQL 17 リリースノート |
| 2025-09-25 | PostgreSQL 18 リリース。uuidv7() と別名 uuidv4() を追加 |
PostgreSQL 18 リリースノート |
| Python 3.14 | uuid.uuid7() を追加。42ビットのカウンタでミリ秒内の単調性を保証 |
Python ドキュメント |
PostgreSQL 18 の uuidv7() は、公式ドキュメントによれば「ミリ秒の時刻+ミリ秒未満の時刻+乱数」で組まれます。本記事の計測は PostgreSQL 16 で行ったため、uuidv7() 自体の性能は確かめていません(未検証)。
対策:IDの選び方と認可チェックの型
まず全ての読み書きで所有者を確かめる
OWASP API1:2023 の対策は「クライアントからの入力でレコードに触る全ての関数で、ログイン中のユーザーにその操作の権限があるかを確かめる」ことです。乱数の ID を使うことも勧めていますが、位置づけは多層防御の1枚です。
OWNER CHECK認可チェックの門
IDの形に関係なく持ち主の確認で止める
所有者の照合
- 自分自分の注文を開く 一致 → 200GET /orders/自分のID
- 推測連番を1つずらす 不一致 → 404GET /orders/ID+1
- 漏れ共有リンクのID 不一致 → 404GET /orders/他人のUUID
実装では、ID だけで引いてから権限を見るのではなく、WHERE id = $1 AND owner_id = $2 のように所有者の条件を検索そのものに入れると、確認の書き忘れが起きにくくなります。他人のオブジェクトには 403 ではなく 404 を返せば、ID が存在するかどうかも漏れません。認可の判定が設定次第で素通りする事例は next-auth の fail-open 脆弱性で扱いました。テストには「別ユーザーの ID で叩くと拒否される」ケースを必ず入れてください。
IDの設計を選ぶ
| 設計 | 推測されにくさ | 挿入の性能(本記事の実測) | 外に漏れる情報 | 向く場面 |
|---|---|---|---|---|
| 連番 | 低い(列挙できる) | 最速 | 件数と作成順 | 外部に出さない内部 ID |
| UUIDv4 | 高い | 索引がキャッシュを超えると遅い。SQLite で約18倍 | ほぼ無い | 主キーにしない外部 ID、トークン |
| UUIDv7 | 高い(乱数は最大74ビット) | 連番に近い。ミリ秒内の単調性が要る | 作成日時 | 分散生成の主キー、時刻が知られてもよい ID |
| 内部連番+外部 ID | 外部 ID しだい | 索引が2本。PG で最も遅かった | 外部 ID の形式しだい | 既存の連番を保ったまま URL を変えたいとき |
どの設計を選んでも、表の「推測されにくさ」は認可チェックの代わりになりません。
参照ソース
- NICT「相次ぐ情報漏えい、その原因の一つは『アプリ解析からのサーバ攻撃』」(2026-10-13)
- RFC 9562: Universally Unique IDentifiers (UUIDs)(本文は IETF uuidrev WG の最終草稿 draft-ietf-uuidrev-rfc4122bis-14 の原稿で確認)
- OWASP API Security Top 10 2023 — API1:2023 Broken Object Level Authorization
- OWASP Cheat Sheet Series — Insecure Direct Object Reference Prevention
- PostgreSQL 18 Documentation — UUID Functions
- PostgreSQL 18 Release Notes
- MySQL 8.4 Reference Manual — Clustered and Secondary Indexes
- Python 3.14 Documentation — uuid.uuid7()





