コンテンツにスキップ

Bearer トークン

Bearer トークン — RFC 6750

  • Bearer は BasicDigest と同じ 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 の自動送信に依存しない

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 b64token
b64token = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"="

トークンの中身は Bearer の仕様が決めていない。 b64token という文字集合だけが定義されていて、不透明な文字列でも JWT でもこの形に収まる。

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

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 方式に対応する。名前が変わっても、トレードオフは変わらない。

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

JWT の 3 つのセグメントと主要クレーム。図中の短い例は eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1OCIsImV4cCI6MTc4OX0.74SnNRdC6

図では短い例を使っている。構造は同じで、. 区切りの 3 セグメント。前 2 つは base64url なので誰でも読める。 以下は実際の長さのトークン。

実際の JWT
eyJhbGciOiJIUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6IjIwMjYtMDkifQ
.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyXzhmM2E5MWM0IiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJleHAiOjE3ODkwMDA5MDAsImlhdCI6MTc4OTAwMDAwMCwic2NvcGUiOiJyZWFkOnByb2ZpbGUgd3JpdGU6cG9zdHMiLCJqdGkiOiI3YjZjNmNhYSJ9
.J0bh8NmMvrNRK8UUgboNICNAkuZG5pWK9W-z34m8s5A
header をデコードした結果
{ "alg": "HS256", "typ": "at+jwt", "kid": "2026-09" }
payload をデコードした結果
{
"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 が秘密ではない証拠。 鍵も要らない。

署名を検証するだけでは足りない。 クレームの検証まで含めて初めて認証になる。

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. トークンが漏れたら何が起きるか”

Bearer トークンは拾った人が使えるが、sender-constrained トークンは鍵がないと使えない

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 から見ると後退した選択になりつつある。

手段 何ができるか 代償
短い有効期限(数分〜1 時間) 漏洩の影響時間を縮める refresh の往復が増える
refresh token のローテーション 使い回しを検知して系統ごと無効化できる 状態管理が必要
introspectionRFC 7662 即座に失効を反映できる リクエストごとに往復
失効リストjti を持つ) 個別に止められる 実質ステートフルになる
revocationRFC 7009 クライアントからトークンを破棄できる 認可サーバー側の対応が必要

JWT を選んだ時点で「即座の失効」は諦めている。 それが困るなら不透明トークン + introspection にする。RFC 9700 は公開クライアントの refresh token について、sender-constrained にするか、ローテーションするかのどちらかを MUST としている。

RFC 8725 が挙げているものを、実際の攻撃と対応付ける。

algnone にして署名を空にした JWT を作ると、検証を通してしまう実装がある。

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 は鍵を選ぶための識別子。そのまま SQL やファイルパスに渡すと注入になる。RFC 8725 は検証とサニタイズを求めている
  • jku / x5u は鍵の取得先 URL。言われたとおりに取りに行くと SSRF になる。許可リストで縛る

鍵の選び方は受信側が決める。 トークンに書かれた場所から鍵を取ってきて、そのトークンを検証するのは循環している。

aud を見ないと、別のサービス向けに発行されたトークンが自分のところで通る(RFC 8725 が言う substitution attack)。同じ認可サーバーから複数の API が発行を受けている構成で刺さる。

exp を検証しない実装は、一度発行されたトークンが永久に有効になる。クロックスキューを見込んで数十秒の猶予を持たせるのはよいが、猶予を数分にすると失効が効かなくなる。

署名を検証せずにデコードする

Section titled “署名を検証せずにデコードする”

jwt.decode() は署名を検証しない。ログ出力やデバッグのために decode したコードが、そのまま認証経路に残るのが典型的な事故。

方式 クレデンシャルの出どころ 有効期限 失効 権限の範囲 第三者への委譲
Basic 利用者が知っている なし できない 全部か無し できない
Digest 利用者が知っている nonce のみ できない 全部か無し できない
Cookie セッション ログインで発行 あり できる セッションに紐付く できない
Bearer(不透明) 認可サーバーが発行 あり できる scope で絞れる できる
Bearer(JWT) 認可サーバーが発行 あり 個別には難しい scope で絞れる できる
DPoP / mTLS 認可サーバーが発行 + 鍵 あり できる scope で絞れる できる

scope と委譲ができるのが Bearer だけ。 逆に言えば、その 2 つが要らないなら Bearer を選ぶ理由は薄い。

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

Section titled “使ってよいケースと使ってはいけないケース”
  • TLS が強制されている(RFC 6750 が TLS を MUST としている)
  • トークンを Authorization ヘッダで送れる(クエリやボディに逃げない)
  • 有効期限を短くし、refresh の仕組みを用意できる
  • iss / aud / exp / alg をすべて検証している(JWT の場合)
  • トークンをクライアント側で安全に保持できるlocalStorage に置かない)
  • 失効要件に合う方式を選べている(即座の失効が要るなら不透明トークン)

Bearer トークンが成立する 6 つの条件

判断の分岐はこの順に辿る。

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
ケース 成立する理由 一緒にやること
公開 API / サーバー間 API Cookie の自動送信という前提がない。scope で権限を絞れる 短い有効期限、aud を API ごとに分ける
モバイルアプリ Cookie ストアが OS 依存にならない。失効も設計できる OS のセキュアストレージに保持、PKCE を使う
第三者アプリへの権限委譲 パスワードを渡さずに限定的な権限だけ渡せる OAuth 2.0 の認可コードフロー + PKCE
マイクロサービス間の伝播 JWT なら各サービスが独立して検証できる aud をサービスごとに設定、短命化
クロスサイトで使う SPA Cookie が SameSite で止まる構成でも動く メモリ保持、localStorage を避ける

「マイクロサービスで JWT が成立する理由」を分解する。 JWT の弱点は「個別に失効できない」ことだが、サービス間の内部通信ではトークンの寿命が数分で、呼び出し元も自分たちの管理下にある。失効が必要になる場面(退職、権限剥奪)は入口の認可サーバーで止めればよく、内部の短命トークンまで追いかける必要がない。弱点が刺さる時間窓が存在しないため成立する。

ケース できないこと
ブラウザだけのアプリで Cookie が使える構成 localStorage 保持なら XSS で丸ごと盗まれる。HttpOnly Cookie のほうが安全
即座の失効が要件なのに JWT を選ぶ 期限が切れるまで止められない。退職者のアクセスが残る
TLS がない経路 トークンが平文で流れ、拾った者がそのまま使える
aud を検証しない実装 他 API 向けのトークンで通る(substitution attack)
クエリパラメータで送る設計 ログ・Referer・履歴に残る。仕様自身が SHOULD NOT
payload に個人情報を入れる base64url なので誰でも読める。署名は秘匿を守らない
長寿命の JWT を配る 漏洩時に止める手段がない。RFC 9700 の方向と逆
Authorization ヘッダで送る
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"
verify.js
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 を信じる実装になりうる
  • issueraudience を渡す。 検証してくれる引数があるのに使わないのが最も多い抜け
  • jwt.decode() を認証経路に置かない。 署名を検証しない
  • 401 と 403 を取り違えない。 トークンが不正なら 401、scope 不足なら 403
  • WWW-Authenticate を付ける。 401 では仕様上必須

不透明トークンを introspection で検証する

Section titled “不透明トークンを introspection で検証する”
introspect.js
// 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 なら無効。短時間のキャッシュを入れるなら、失効の反映が遅れることを受け入れた上で秒単位にする。

置き場所 XSS 耐性 CSRF 耐性 備考
localStorage / sessionStorage 低い(JS から読める) 高い 広く使われているが、盗まれたら終わり
JavaScript のメモリ変数 中(リロードで消える) 高い リロード時に再取得が必要
HttpOnly Cookie 高い 低い(CSRF 対策が必要) 同一サイトならこの選択が有力

「SPA だから localStorage」は選択理由になっていない。 同一サイトに API を置けるなら HttpOnly Cookie のほうが XSS 耐性は高い。クロスサイトが要件なら Bearer + メモリ保持にして、リロード時は refresh で取り直す。

  • Authorization ヘッダをログに出さない。 Basic 認証と同じ問題で、Sentry や APM のヘッダ自動収集が経路になる
  • error_description は開発者向け。 「そのトークンは 3 分前に失効しました」のような内部情報を利用者に返さない
  • トークン本体をエラーメッセージに含めない

RFC は具体的な数値を示していないので、失効の要件から逆算する。

トークン 目安 根拠
access token(JWT) 数分〜15 分 失効できないぶん、時間窓で抑える
access token(不透明) 1 時間程度 introspection で止められるので長くてよい
refresh token 数日〜数週間 ローテーションと組み合わせる
  • 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 をどう発行するか」の現行基準はここにある。

  • 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 で丸ごと盗まれる。 同一サイトなら HttpOnly Cookie が有力。
  • 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 のヘッダの algnone に書き換え、署名を空にして送る攻撃。検証側が受け取った alg を信じて分岐していると、署名なしのトークンを受け入れてしまう。payload は自由に書き換えられるので、sub を管理者に変えて成り代われる。

アルゴリズム混同(RS256 を HS256 にする)攻撃はなぜ成立するか。

RS256 は公開鍵で検証するが、HS256 は共有鍵で検証する。alg を HS256 に書き換えると、検証側が「公開鍵を HMAC の共有鍵として」使ってしまう実装がある。公開鍵は公開されているので、攻撃者はその鍵で正しい署名を作れる。対策は、受け取った alg を使わず検証側で許可アルゴリズムを固定すること。

kid、jku、x5u ヘッダの扱いで注意することは。

kid は鍵を選ぶ識別子だが、そのまま SQL やファイルパスに渡すと注入になるので検証とサニタイズが必要。jkux5u は鍵の取得先 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 の応答で「有効かどうか」を判断するフィールドは。

activetrue 以外はすべて無効として扱う。他のフィールドが返っていても active: false なら使ってはいけない。

RFC 9700 が使うなと言っている OAuth 2.0 のグラントは。

implicit グラント(response_type=token)は SHOULD NOT、resource owner password credentials グラントは MUST NOT。前者はアクセストークンが漏れやすく、後者は利用者の資格情報をクライアントに渡すことになる。