Bearer トークン

3 行まとめ
Section titled “3 行まとめ”- Bearer は Basic や Digest と同じ HTTP 認証フレームワーク (RFC 9110 §11) のスキームで、
Authorization: Bearer <token>を送るだけ。仕様は RFC 6750。 bearerは「持参人」の意味。 鍵の所有を証明せず、トークンを持っている者は誰でも使える。これが利点(軽い)でもあり、最大の弱点でもある。- 「認証」と「発行」が分離されているのが Basic / Digest との決定的な差。トークンをどう作るかは Bearer の仕様外で、そこは OAuth 2.0 の領域。
HTTP 認証フレームワークの中の位置づけ
Section titled “HTTP 認証フレームワークの中の位置づけ”枠組みは Basic / Digest とまったく同じ。違うのはクレデンシャルの出どころ。
flowchart TB
subgraph FW["HTTP 認証フレームワーク (RFC 9110 §11)"]
direction TB
C["チャレンジ<br/>401 + WWW-Authenticate"]
R["クレデンシャル送信<br/>Authorization ヘッダ"]
C --> R
end
FW --> S1["Basic<br/>パスワードを毎回送る"]
FW --> S2["Digest<br/>nonce を混ぜたハッシュ"]
FW --> S3["Bearer<br/>発行されたトークンを送る"]
S1 --> N1["クレデンシャルは<br/>利用者が知っている"]
S2 --> N1
S3 --> N2["クレデンシャルは<br/>別のサーバーが発行する"]
style S3 fill:#4054b2,color:#fff
style N2 fill:#1f5c33,color:#fff
Basic と Digest は「パスワードを知っていること」を毎回証明する。Bearer は「発行を受けたこと」を示す。 この違いから、Bearer だけが持つ性質が出てくる。
- 有効期限と失効を設計できる。 トークンは発行物なので寿命を持てる
- 権限の範囲を絞れる。
scopeで「何ができるか」を限定できる - 第三者に渡せる。 利用者のパスワードを渡さずに、アプリに限定的な権限を委譲できる(OAuth 2.0)
- ブラウザ以外でも自然に使える。 Cookie の自動送信に依存しない
トークンの送り方
Section titled “トークンの送り方”RFC 6750 は 3 つの方法を定義しているが、推奨度がまったく違う。
| 方法 | 仕様の言い方 | 使うか |
|---|---|---|
Authorization: Bearer <token> ヘッダ |
クライアントは SHOULD 使う。リソースサーバーは MUST 対応 | これを使う |
access_token フォームボディ |
Authorization ヘッダにアクセスできない環境を除き SHOULD NOT |
使わない |
access_token URI クエリ |
セキュリティ上の弱点があり SHOULD NOT。他に手段がない場合のみ | 使わない |
クエリパラメータが特に危険な理由は、仕様自身が「URL に含まれたアクセストークンがログに記録される可能性が高い」と書いている。URL は Referer・アクセスログ・ブラウザ履歴・共有リンクを通じて漏れる。Basic 認証で URL にクレデンシャルを埋めてはいけないのと同じ理由。
credentials = "Bearer" 1*SP b64tokenb64token = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"="トークンの中身は Bearer の仕様が決めていない。 b64token という文字集合だけが定義されていて、不透明な文字列でも JWT でもこの形に収まる。
チャレンジとエラー応答
Section titled “チャレンジとエラー応答”WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="The access token expired"| 属性 | 意味 |
|---|---|
realm |
保護空間の名前(任意) |
scope |
必要な scope の空白区切りリスト(任意) |
error |
下記のエラーコード(任意) |
error_description |
開発者向けの説明。利用者向けではない |
error_uri |
説明ページの URI |
エラーコードは 3 つだけで、それぞれ対応するステータスコードが決まっている。
error |
意味 | ステータス |
|---|---|---|
invalid_request |
パラメータ不足・不正、複数の方法でトークンを送った | 400 |
invalid_token |
トークンが期限切れ・失効済み・不正 | 401 |
insufficient_scope |
認証は通ったが権限(scope)が足りない | 403 |
データフローを追う
Section titled “データフローを追う”1. 発行と提示は別のサーバーで起きる
Section titled “1. 発行と提示は別のサーバーで起きる”Bearer の理解でいちばん効くのは、「トークンをもらう往復」と「トークンを使う往復」が別だと分かること。
sequenceDiagram
autonumber
participant C as クライアント
participant AS as 認可サーバー
participant RS as リソースサーバー
Note over C,AS: ここは OAuth 2.0 の領域<br/>RFC 6750 の対象外
C->>AS: 資格情報や認可コードを提示
AS-->>C: access_token と expires_in<br/>refresh_token
Note over C,RS: ここが RFC 6750 の対象
C->>RS: GET /me<br/>Authorization: Bearer eyJhbGci...
RS->>RS: トークンを検証する
RS-->>C: 200 OK
alt トークンが期限切れ
RS-->>C: 401 + error=invalid_token
C->>AS: refresh_token で再発行
AS-->>C: 新しい access_token
else scope が足りない
RS-->>C: 403 + error=insufficient_scope
Note over C: 再送しても通らない<br/>より広い scope を取り直す
end
リソースサーバーは利用者のパスワードを一切知らない。 これが Basic / Digest との構造的な違いで、権限委譲が成立する理由でもある。
2. 不透明トークンと JWT で検証の場所が変わる
Section titled “2. 不透明トークンと JWT で検証の場所が変わる”flowchart TB
subgraph OPAQUE["不透明トークン"]
direction TB
O1["トークンは意味のない文字列"]
O2["リソースサーバーは中身を読めない"]
O3["認可サーバーに問い合わせて検証<br/>RFC 7662 introspection"]
O4["即座に失効できる"]
O5["毎回ネットワーク往復が増える"]
O1 --> O2 --> O3 --> O4 --> O5
end
subgraph JWT["JWT"]
direction TB
J1["トークン自体に情報が入る"]
J2["リソースサーバーが署名を検証"]
J3["問い合わせ不要で完結する"]
J4["個別の失効ができない"]
J5["期限が切れるまで有効なまま"]
J1 --> J2 --> J3 --> J4 --> J5
end
style O4 fill:#1f5c33,color:#fff
style O5 fill:#7a4a12,color:#fff
style J3 fill:#1f5c33,color:#fff
style J4 fill:#7a2222,color:#fff
選択の軸は Cookie セッションのときと同じ「個別に失効させたいか」。 構造も同じで、不透明トークンはサーバー側ストア方式、JWT は署名付き Cookie 方式に対応する。名前が変わっても、トレードオフは変わらない。
3. JWT の中身を分解する
Section titled “3. JWT の中身を分解する”flowchart TB
T["JWT 全体<br/>339 文字"] --> H["header<br/>62 文字"]
T --> P["payload<br/>232 文字"]
T --> S["signature<br/>43 文字"]
H --> HD["alg typ kid"]
P --> PD["iss sub aud exp iat scope jti"]
S --> SD["header と payload の<br/>base64url を連結して署名"]
style SD fill:#1f5c33,color:#fff

図では短い例を使っている。構造は同じで、. 区切りの 3 セグメント。前 2 つは base64url なので誰でも読める。 以下は実際の長さのトークン。
eyJhbGciOiJIUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6IjIwMjYtMDkifQ.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyXzhmM2E5MWM0IiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJleHAiOjE3ODkwMDA5MDAsImlhdCI6MTc4OTAwMDAwMCwic2NvcGUiOiJyZWFkOnByb2ZpbGUgd3JpdGU6cG9zdHMiLCJqdGkiOiI3YjZjNmNhYSJ9.J0bh8NmMvrNRK8UUgboNICNAkuZG5pWK9W-z34m8s5A{ "alg": "HS256", "typ": "at+jwt", "kid": "2026-09" }{ "iss": "https://auth.example.com", "sub": "user_8f3a91c4", "aud": "https://api.example.com", "exp": 1789000900, "iat": 1789000000, "scope": "read:profile write:posts", "jti": "7b6c6caa"}| クレーム | 意味 | 検証で使うか |
|---|---|---|
iss |
発行者 | 必須で検証する |
sub |
主体(利用者の識別子) | アプリが使う |
aud |
想定される受け取り手 | 必須で検証する |
exp |
有効期限 | 必須で検証する |
iat |
発行時刻 | 必要なら |
jti |
トークンの一意な ID | 失効リストで使う |
scope |
許可された権限 | 認可の判定で使う |
# payload だけ取り出して整形する(base64url のパディングを足す)JWT='eyJhbGciOiJIUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6IjIwMjYtMDkifQ.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyXzhmM2E5MWM0IiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJleHAiOjE3ODkwMDA5MDAsImlhdCI6MTc4OTAwMDAwMCwic2NvcGUiOiJyZWFkOnByb2ZpbGUgd3JpdGU6cG9zdHMiLCJqdGkiOiI3YjZjNmNhYSJ9.J0bh8NmMvrNRK8UUgboNICNAkuZG5pWK9W-z34m8s5A'echo "$JWT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | python3 -m json.toolこのコマンドが通ること自体が、payload が秘密ではない証拠。 鍵も要らない。
4. 検証の手順
Section titled “4. 検証の手順”署名を検証するだけでは足りない。 クレームの検証まで含めて初めて認証になる。
flowchart TB
A["Authorization: Bearer を受信"] --> B{"3 セグメントに<br/>分割できるか"}
B -->|"できない"| E400["400 invalid_request"]
B -->|"できる"| C{"alg は自分が<br/>許可した値か"}
C -->|"違う・none"| E401["401 invalid_token"]
C -->|"許可した値"| D["kid で鍵を選ぶ<br/>値をそのまま検索に使わない"]
D --> F{"署名は正しいか"}
F -->|"不正"| E401
F -->|"正しい"| G{"iss は期待する<br/>発行者か"}
G -->|"違う"| E401
G -->|"一致"| H{"aud に自分が<br/>含まれるか"}
H -->|"含まれない"| E401
H -->|"含まれる"| I{"exp は未来か"}
I -->|"期限切れ"| E401
I -->|"有効"| J{"scope は<br/>足りているか"}
J -->|"足りない"| E403["403 insufficient_scope"]
J -->|"足りている"| OK["処理を続ける"]
style OK fill:#1f5c33,color:#fff
style E401 fill:#7a2222,color:#fff
alg を先に検証するのが重要。受け取った JWT の alg を信じて処理を分岐させると、後述の攻撃が通る。
5. トークンが漏れたら何が起きるか
Section titled “5. トークンが漏れたら何が起きるか”
RFC 6750 自身がこう書いている。
Bearer トークンを所持している者(bearer)は誰でも、暗号鍵の所持を証明することなく、関連するリソースへアクセスできる。
つまり Bearer トークンは切符であって、定期券ではない。拾った人がそのまま使える。
flowchart TB
L["トークンが漏れた"] --> Q{"sender-constrained か"}
Q -->|"Bearer のまま"| B1["拾った者がそのまま使える"]
B1 --> B2["有効期限が切れるまで止まらない"]
B2 --> B3["対策は短命化と失効の仕組み"]
Q -->|"DPoP や mTLS"| D1["鍵の所持証明が必要"]
D1 --> D2["トークンだけでは使えない"]
D2 --> D3["漏洩の影響が大きく下がる"]
style B2 fill:#7a2222,color:#fff
style D3 fill:#1f5c33,color:#fff
RFC 9700 は、認可サーバーに対して mTLS や DPoP による sender-constrained なアクセストークンの利用を SHOULD としている。つまり「Bearer のまま使う」のは現行 BCP から見ると後退した選択になりつつある。
6. 失効の難しさ
Section titled “6. 失効の難しさ”| 手段 | 何ができるか | 代償 |
|---|---|---|
| 短い有効期限(数分〜1 時間) | 漏洩の影響時間を縮める | refresh の往復が増える |
| refresh token のローテーション | 使い回しを検知して系統ごと無効化できる | 状態管理が必要 |
| introspection(RFC 7662) | 即座に失効を反映できる | リクエストごとに往復 |
失効リスト(jti を持つ) |
個別に止められる | 実質ステートフルになる |
| revocation(RFC 7009) | クライアントからトークンを破棄できる | 認可サーバー側の対応が必要 |
JWT を選んだ時点で「即座の失効」は諦めている。 それが困るなら不透明トークン + introspection にする。RFC 9700 は公開クライアントの refresh token について、sender-constrained にするか、ローテーションするかのどちらかを MUST としている。
JWT の落とし穴
Section titled “JWT の落とし穴”RFC 8725 が挙げているものを、実際の攻撃と対応付ける。
alg: none を受け付けてしまう
Section titled “alg: none を受け付けてしまう”alg を none にして署名を空にした JWT を作ると、検証を通してしまう実装がある。
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyXzhmM2E5MWM0IiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJleHAiOjE3ODkwMDA5MDAsImlhdCI6MTc4OTAwMDAwMCwic2NvcGUiOiJyZWFkOnByb2ZpbGUgd3JpdGU6cG9zdHMiLCJqdGkiOiI3YjZjNmNhYSJ9.payload は書き換え自由で、署名は不要。 sub を管理者に変えるだけで成り代われる。
アルゴリズム混同(RS256 を HS256 に変える)
Section titled “アルゴリズム混同(RS256 を HS256 に変える)”RS256(公開鍵で検証)を HS256(共有鍵で検証)に書き換える攻撃。公開鍵は公開されているので、それを HMAC の鍵として使って署名を作れてしまう。
RFC 8725 はこの 2 つを名指しで挙げ、対策を 1 行で示している。
ライブラリは、呼び出し側が対応アルゴリズムの集合を指定できるようにしなければならず、それ以外のアルゴリズムを暗号処理に使ってはならない。
受け取った alg で分岐させず、自分が期待するアルゴリズムを検証側で固定する。
// 悪い: トークンの alg を信じているjwt.verify(token, key);
// 良い: 許可するアルゴリズムを検証側が指定するjwt.verify(token, key, { algorithms: ['RS256'], // これ以外は拒否 issuer: 'https://auth.example.com', // iss を検証 audience: 'https://api.example.com', // aud を検証});kid / jku / x5u を信じてしまう
Section titled “kid / jku / x5u を信じてしまう”kidは鍵を選ぶための識別子。そのまま SQL やファイルパスに渡すと注入になる。RFC 8725 は検証とサニタイズを求めているjku/x5uは鍵の取得先 URL。言われたとおりに取りに行くと SSRF になる。許可リストで縛る
鍵の選び方は受信側が決める。 トークンに書かれた場所から鍵を取ってきて、そのトークンを検証するのは循環している。
aud と iss を検証しない
Section titled “aud と iss を検証しない”aud を見ないと、別のサービス向けに発行されたトークンが自分のところで通る(RFC 8725 が言う substitution attack)。同じ認可サーバーから複数の API が発行を受けている構成で刺さる。
有効期限を見ない
Section titled “有効期限を見ない”exp を検証しない実装は、一度発行されたトークンが永久に有効になる。クロックスキューを見込んで数十秒の猶予を持たせるのはよいが、猶予を数分にすると失効が効かなくなる。
署名を検証せずにデコードする
Section titled “署名を検証せずにデコードする”jwt.decode() は署名を検証しない。ログ出力やデバッグのために decode したコードが、そのまま認証経路に残るのが典型的な事故。
他の認証方式との比較
Section titled “他の認証方式との比較”| 方式 | クレデンシャルの出どころ | 有効期限 | 失効 | 権限の範囲 | 第三者への委譲 |
|---|---|---|---|---|---|
| Basic | 利用者が知っている | なし | できない | 全部か無し | できない |
| Digest | 利用者が知っている | nonce のみ | できない | 全部か無し | できない |
| Cookie セッション | ログインで発行 | あり | できる | セッションに紐付く | できない |
| Bearer(不透明) | 認可サーバーが発行 | あり | できる | scope で絞れる |
できる |
| Bearer(JWT) | 認可サーバーが発行 | あり | 個別には難しい | scope で絞れる |
できる |
| DPoP / mTLS | 認可サーバーが発行 + 鍵 | あり | できる | scope で絞れる |
できる |
scope と委譲ができるのが Bearer だけ。 逆に言えば、その 2 つが要らないなら Bearer を選ぶ理由は薄い。
使ってよいケースと使ってはいけないケース
Section titled “使ってよいケースと使ってはいけないケース”成立の前提条件
Section titled “成立の前提条件”- TLS が強制されている(RFC 6750 が TLS を MUST としている)
- トークンを
Authorizationヘッダで送れる(クエリやボディに逃げない) - 有効期限を短くし、refresh の仕組みを用意できる
-
iss/aud/exp/algをすべて検証している(JWT の場合) - トークンをクライアント側で安全に保持できる(
localStorageに置かない) - 失効要件に合う方式を選べている(即座の失効が要るなら不透明トークン)

判断の分岐はこの順に辿る。
flowchart TB
S["Bearer トークンを使いたい"] --> Q1{"TLS が<br/>強制されているか"}
Q1 -->|"いいえ"| NG1["使わない<br/>まず TLS を用意する"]
Q1 -->|"はい"| Q2{"scope で権限を絞るか<br/>第三者に委譲するか"}
Q2 -->|"どちらも不要"| Q3{"クライアントは<br/>ブラウザだけか"}
Q3 -->|"ブラウザだけ"| NG2["Cookie セッションで足りる"]
Q3 -->|"他もある"| OK1["使える<br/>不透明トークン"]
Q2 -->|"必要"| Q4{"即座の失効が<br/>必要か"}
Q4 -->|"必要"| OK2["使える<br/>不透明 + introspection"]
Q4 -->|"不要"| OK3["使える<br/>短命な JWT + refresh"]
style OK1 fill:#1f5c33,color:#fff
style OK2 fill:#1f5c33,color:#fff
style OK3 fill:#1f5c33,color:#fff
style NG1 fill:#7a2222,color:#fff
style NG2 fill:#7a4a12,color:#fff
使ってよいケース
Section titled “使ってよいケース”| ケース | 成立する理由 | 一緒にやること |
|---|---|---|
| 公開 API / サーバー間 API | Cookie の自動送信という前提がない。scope で権限を絞れる |
短い有効期限、aud を API ごとに分ける |
| モバイルアプリ | Cookie ストアが OS 依存にならない。失効も設計できる | OS のセキュアストレージに保持、PKCE を使う |
| 第三者アプリへの権限委譲 | パスワードを渡さずに限定的な権限だけ渡せる | OAuth 2.0 の認可コードフロー + PKCE |
| マイクロサービス間の伝播 | JWT なら各サービスが独立して検証できる | aud をサービスごとに設定、短命化 |
| クロスサイトで使う SPA | Cookie が SameSite で止まる構成でも動く |
メモリ保持、localStorage を避ける |
「マイクロサービスで JWT が成立する理由」を分解する。 JWT の弱点は「個別に失効できない」ことだが、サービス間の内部通信ではトークンの寿命が数分で、呼び出し元も自分たちの管理下にある。失効が必要になる場面(退職、権限剥奪)は入口の認可サーバーで止めればよく、内部の短命トークンまで追いかける必要がない。弱点が刺さる時間窓が存在しないため成立する。
使ってはいけないケース
Section titled “使ってはいけないケース”| ケース | できないこと |
|---|---|
| ブラウザだけのアプリで Cookie が使える構成 | localStorage 保持なら XSS で丸ごと盗まれる。HttpOnly Cookie のほうが安全 |
| 即座の失効が要件なのに JWT を選ぶ | 期限が切れるまで止められない。退職者のアクセスが残る |
| TLS がない経路 | トークンが平文で流れ、拾った者がそのまま使える |
aud を検証しない実装 |
他 API 向けのトークンで通る(substitution attack) |
| クエリパラメータで送る設計 | ログ・Referer・履歴に残る。仕様自身が SHOULD NOT |
| payload に個人情報を入れる | base64url なので誰でも読める。署名は秘匿を守らない |
| 長寿命の JWT を配る | 漏洩時に止める手段がない。RFC 9700 の方向と逆 |
curl で送る
Section titled “curl で送る”TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6ImF0K2p3dCJ9...'
# これが唯一の推奨経路curl -sS -H "Authorization: Bearer $TOKEN" https://api.example.com/me
# エラー応答を見るcurl -sS -i -H "Authorization: Bearer expired.token.here" https://api.example.com/me# HTTP/1.1 401 Unauthorized# WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="..."
# クエリで送ってはいけない(URL がログに残る)# curl "https://api.example.com/me?access_token=$TOKEN"Express で JWT を検証する
Section titled “Express で JWT を検証する”import { createRemoteJWKSet, jwtVerify } from 'jose';
// 鍵は自分が知っている場所から取る。トークンの jku を信じないconst JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));
const ISSUER = 'https://auth.example.com';const AUDIENCE = 'https://api.example.com';
export function requireScope(...required) { return async (req, res, next) => { const header = req.get('authorization') ?? ''; const [scheme, token] = header.split(' ');
if (!token || scheme.toLowerCase() !== 'bearer') { return challenge(res, 401, 'invalid_token', 'No bearer token'); }
let payload; try { ({ payload } = await jwtVerify(token, JWKS, { algorithms: ['RS256'], // alg を検証側で固定する issuer: ISSUER, // iss を検証する audience: AUDIENCE, // aud を検証する clockTolerance: 30, // クロックスキューは 30 秒まで })); } catch { return challenge(res, 401, 'invalid_token', 'Token verification failed'); }
const granted = new Set((payload.scope ?? '').split(' ')); if (!required.every((s) => granted.has(s))) { return challenge(res, 403, 'insufficient_scope', `Requires ${required.join(' ')}`); }
req.auth = payload; next(); };}
function challenge(res, status, error, description) { res.set( 'WWW-Authenticate', `Bearer realm="api", error="${error}", error_description="${description}"` ); res.status(status).end();}app.get('/me', requireScope('read:profile'), (req, res) => { res.json({ sub: req.auth.sub });});
app.post('/posts', requireScope('write:posts'), createPost);自前実装で間違えやすい点。
algorithmsを必ず渡す。 省略するとトークンのalgを信じる実装になりうるissuerとaudienceを渡す。 検証してくれる引数があるのに使わないのが最も多い抜けjwt.decode()を認証経路に置かない。 署名を検証しない- 401 と 403 を取り違えない。 トークンが不正なら 401、scope 不足なら 403
WWW-Authenticateを付ける。 401 では仕様上必須
不透明トークンを introspection で検証する
Section titled “不透明トークンを introspection で検証する”// RFC 7662。即座の失効が要件なら、往復のコストを払ってこちらにするasync function introspect(token) { const res = await fetch('https://auth.example.com/introspect', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64'), }, body: new URLSearchParams({ token, token_type_hint: 'access_token' }), }); const data = await res.json(); return data.active === true ? data : null; // active が true 以外は無効}active フィールドだけが「有効か」の答え。 他のフィールドがあっても active: false なら無効。短時間のキャッシュを入れるなら、失効の反映が遅れることを受け入れた上で秒単位にする。
運用で押さえる点
Section titled “運用で押さえる点”トークンをどこに置くか
Section titled “トークンをどこに置くか”| 置き場所 | XSS 耐性 | CSRF 耐性 | 備考 |
|---|---|---|---|
localStorage / sessionStorage |
低い(JS から読める) | 高い | 広く使われているが、盗まれたら終わり |
| JavaScript のメモリ変数 | 中(リロードで消える) | 高い | リロード時に再取得が必要 |
HttpOnly Cookie |
高い | 低い(CSRF 対策が必要) | 同一サイトならこの選択が有力 |
「SPA だから localStorage」は選択理由になっていない。 同一サイトに API を置けるなら HttpOnly Cookie のほうが XSS 耐性は高い。クロスサイトが要件なら Bearer + メモリ保持にして、リロード時は refresh で取り直す。
ログとエラーメッセージ
Section titled “ログとエラーメッセージ”Authorizationヘッダをログに出さない。 Basic 認証と同じ問題で、Sentry や APM のヘッダ自動収集が経路になるerror_descriptionは開発者向け。 「そのトークンは 3 分前に失効しました」のような内部情報を利用者に返さない- トークン本体をエラーメッセージに含めない
有効期限の目安
Section titled “有効期限の目安”RFC は具体的な数値を示していないので、失効の要件から逆算する。
| トークン | 目安 | 根拠 |
|---|---|---|
| access token(JWT) | 数分〜15 分 | 失効できないぶん、時間窓で抑える |
| access token(不透明) | 1 時間程度 | introspection で止められるので長くてよい |
| refresh token | 数日〜数週間 | ローテーションと組み合わせる |
RFC 9700 が求める方向
Section titled “RFC 9700 が求める方向”- PKCE を使う(公開クライアントは MUST、機密クライアントも RECOMMENDED)
- redirect URI は完全一致で照合する
- implicit グラントを使わない(
response_type=token) - resource owner password credentials グラントを使わない(MUST NOT)
- refresh token は sender-constrained かローテーションのどちらか(公開クライアントは MUST)
- access token は
audでリソースサーバーを限定する
この 6 点は Bearer を扱うなら知っておく。 詳細は OAuth 2.0 のフローの話になるので別トピックだが、「Bearer をどう発行するか」の現行基準はここにある。
引っかかりやすいポイント
Section titled “引っかかりやすいポイント”- Bearer の仕様は RFC 6750。ただし RFC 9700 が 6749 / 6750 / 6819 を更新している。
-
bearerは「持参人」。 鍵の所持を証明せず、持っている者は誰でも使える。 - 送り方は 3 つあるが、
Authorizationヘッダ以外は SHOULD NOT。クエリは特に危険。 - エラーは 3 つ。
invalid_request→ 400、invalid_token→ 401、insufficient_scope→ 403。 - 複数の方法でトークンを送ると
invalid_request(400)。 - トークンの中身は Bearer の仕様外。 不透明でも JWT でもよい。
- JWT の payload は base64url。誰でも読める。 個人情報を入れない。
-
algは検証側で固定する。 トークンのalgを信じるとnoneと RS256→HS256 が通る。 -
iss/aud/expを必ず検証する。aud未検証は substitution attack になる。 -
kidをそのまま検索に使わない。jku/x5uを信じない(注入と SSRF)。 -
jwt.decode()は署名を検証しない。 認証経路に置かない。 - JWT は個別に失効できない。 必要なら不透明トークン + introspection。
- introspection の答えは
activeフィールドだけ。 -
localStorageは XSS で丸ごと盗まれる。 同一サイトならHttpOnlyCookie が有力。 - RFC 9700 は sender-constrained トークン(DPoP / mTLS)を SHOULD としている。
- RFC 9700 は implicit と password グラントを使うなと言っている。
bearer という語は何を意味しているか。
「持参人」。RFC 6750 は「Bearer トークンを所持している者は誰でも、暗号鍵の所持を証明することなく関連リソースにアクセスできる」と定義している。つまり切符と同じで、拾った人がそのまま使える。
Bearer トークンの送り方は何通りあり、どれを使うべきか。
3 通り。Authorization: Bearer ヘッダ(クライアントは SHOULD、リソースサーバーは MUST 対応)、フォームボディ(SHOULD NOT)、URI クエリ(SHOULD NOT)。ヘッダ以外は使わない。クエリは URL がログ・Referer・履歴に残るため特に危険で、仕様自身がそう書いている。
RFC 6750 のエラーコードとステータスコードの対応は。
invalid_request が 400、invalid_token が 401、insufficient_scope が 403。複数の方法でトークンを送った場合は invalid_request になる。
Bearer が Basic や Digest と構造的に違う点は。
クレデンシャルの出どころ。Basic と Digest は利用者が知っているパスワードを毎回証明するが、Bearer は別のサーバー(認可サーバー)が発行したトークンを示す。この分離があるため、有効期限・失効・scope による権限の限定・第三者への委譲が設計できる。
不透明トークンと JWT はどう選ぶか。
即座の失効が必要かどうかで選ぶ。不透明トークンはリソースサーバーが認可サーバーに問い合わせる(RFC 7662 introspection)ので即座に失効できるが、毎回往復が増える。JWT は署名検証だけで完結するが、期限が切れるまで個別に失効できない。Cookie セッションの「サーバー側ストアか署名付き Cookie か」とまったく同じトレードオフ。
JWT の payload に個人情報を入れてよいか。
入れてはいけない。payload は base64url で符号化されているだけなので、鍵なしで誰でもデコードできる。署名が守るのは改竄の検知だけで、秘匿は守らない。
alg: none 攻撃とは何か。
JWT のヘッダの alg を none に書き換え、署名を空にして送る攻撃。検証側が受け取った alg を信じて分岐していると、署名なしのトークンを受け入れてしまう。payload は自由に書き換えられるので、sub を管理者に変えて成り代われる。
アルゴリズム混同(RS256 を HS256 にする)攻撃はなぜ成立するか。
RS256 は公開鍵で検証するが、HS256 は共有鍵で検証する。alg を HS256 に書き換えると、検証側が「公開鍵を HMAC の共有鍵として」使ってしまう実装がある。公開鍵は公開されているので、攻撃者はその鍵で正しい署名を作れる。対策は、受け取った alg を使わず検証側で許可アルゴリズムを固定すること。
kid、jku、x5u ヘッダの扱いで注意することは。
kid は鍵を選ぶ識別子だが、そのまま SQL やファイルパスに渡すと注入になるので検証とサニタイズが必要。jku と x5u は鍵の取得先 URL で、言われたとおりに取りに行くと SSRF になる。許可リストで縛る。そもそも鍵の取得元は受信側が決めるべきで、トークンに書かれた場所から鍵を取ってそのトークンを検証するのは循環している。
aud を検証しないと何が起きるか。
別のサービス向けに発行されたトークンが自分のところで通る(RFC 8725 が substitution attack と呼ぶもの)。同じ認可サーバーから複数の API がトークンを受け取っている構成で刺さる。
JWT の検証で最低限やることを挙げよ。
alg を検証側で固定する、署名を検証する、iss が期待する発行者か、aud に自分が含まれるか、exp が未来か。そのうえで scope が足りているかを見る。署名だけ検証してクレームを見ないのは認証になっていない。
sender-constrained トークンとは何で、なぜ推奨されるか。
トークンをクライアントが持つ鍵に紐付け、リクエストごとに鍵の所持を証明させる仕組み(DPoP は RFC 9449、ほかに mTLS)。トークンだけ盗んでも使えないため、漏洩の影響が大きく下がる。RFC 9700 は認可サーバーに対してこの仕組みの利用を SHOULD としている。
SPA でアクセストークンをどこに保持すべきか。
localStorage は JS から読めるので XSS で丸ごと盗まれる。同一サイトに API を置けるなら HttpOnly Cookie が最も XSS 耐性が高い(ただし CSRF 対策が必要)。クロスサイトが要件なら JavaScript のメモリに保持し、リロード時は refresh token で取り直す。「SPA だから localStorage」は選択理由になっていない。
introspection の応答で「有効かどうか」を判断するフィールドは。
active。true 以外はすべて無効として扱う。他のフィールドが返っていても active: false なら使ってはいけない。
RFC 9700 が使うなと言っている OAuth 2.0 のグラントは。
implicit グラント(response_type=token)は SHOULD NOT、resource owner password credentials グラントは MUST NOT。前者はアクセストークンが漏れやすく、後者は利用者の資格情報をクライアントに渡すことになる。
- RFC 6750 — The OAuth 2.0 Authorization Framework: Bearer Token Usage
- RFC 9700 — Best Current Practice for OAuth 2.0 Security
- RFC 8725 — JSON Web Token Best Current Practices
- RFC 7519 — JSON Web Token (JWT)
- RFC 9449 — OAuth 2.0 Demonstrating Proof of Possession (DPoP)
- RFC 7662 — OAuth 2.0 Token Introspection
- RFC 7009 — OAuth 2.0 Token Revocation
- OAuth 2.0 Security Best Current Practice