トークンの構造
以下は、デモ用のシークレット demo-secret-do-not-use で署名した完全な HS256 トークンです。読みやすさのために改行を入れていますが、実際のトークンは 1 行です。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyLTQyIiwibmFtZSI6IkFkYSIsImFkbWluIjpmYWxzZSwiaWF0IjoxNzYwMDAwMDAwLCJleHAiOjE3NjAwMDM2MDB9
.nKTVXOM_r1JbE92WsFd2SSun0a1TByUV8ZpqrRUnIhk
. で区切られた 3 つのパート:
| パート | Base64URL の長さ | デコード結果 |
|---|---|---|
| ヘッダー | 36 | {"alg":"HS256","typ":"JWT"} |
| ペイロード | 104 | {"sub":"user-42","name":"Ada","admin":false,"iat":1760000000,"exp":1760003600} |
| 署名 | 43 | HMAC-SHA256 の生の 32 バイト |
ヘッダーとペイロードは、JSON を UTF-8 でエンコードし、さらに Base64URL でエンコードしたものです。署名はバイナリなので、バイト列としてしか意味を持ちません。iat と exp は秒単位の Unix タイムスタンプで、このトークンは 2025-10-09 08:53:20 UTC に発行され、1 時間後に期限切れになりました。
パディングなしの Base64URL を使う理由
トークンは、+、/、= が問題を起こす場所を通ります。クエリ文字列(+ がスペースになる)、URL のパス(/ は区切り文字)、Cookie、HTTP ヘッダーです。そこで、JWT の土台となる署名形式 JWS を定義した RFC 7515 は、RFC 4648 の URLセーフなアルファベットを使い、パディングを取り除いています。
RFC 4648 の 5 章で定義された URL およびファイル名に安全な文字セットを使う Base64 エンコードで、末尾の ‘=’ 文字はすべて省略し(3.2 節で許可されているとおり)、改行、空白、その他の追加文字は一切含めない。
標準の Base64 からの変更点は 2 つです。
+は-に、/は_になります。上の署名には_が含まれており、標準のデコーダーはこれを拒否します。- 末尾に
=がありません。セグメントの長さが 4n+1 になることはないので、デコーダーは取り除かれたパディングの量を常に割り出せます。長さを 4 で割った余りが 2 なら==、3 なら=です。
アルファベットの違いについて詳しくは Base64 と Base64URL の違い をご覧ください。
各パートを手作業でデコードする
ドットで分割し、標準のアルファベットとパディングに戻してからデコードし、JSON をパースします。ブラウザでは次のとおりです。
function decodePart(part) {
const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
return JSON.parse(new TextDecoder().decode(bytes));
}
const [header, payload] = token.split(".").slice(0, 2).map(decodePart);
TextDecoder の手順を省くと、非 ASCII のテキストが壊れます。{"name":"Zoë 日本"} を含むペイロードは eyJuYW1lIjoiWm_DqyDml6XmnKwifQ にエンコードされます。変換後の文字列を atob だけに通すと、atob は 1 バイトにつき 1 文字を返すため、名前は Zoë で始まる文字化けになります。対応しているランタイムなら、Uint8Array.fromBase64(part, { alphabet: "base64url" }) で最初の 3 行を置き換えられます。Uint8Array.toBase64 をご覧ください。Node.js では Buffer.from(part, "base64url") が同じことをします。
Python では、urlsafe_b64decode がアルファベットを処理しますが、パディングは依然として必要です。
import base64, json
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
header, payload, signature = token.split(".")
print(json.loads(b64url_decode(header))) # {'alg': 'HS256', 'typ': 'JWT'}
print(json.loads(b64url_decode(payload))) # {'sub': 'user-42', 'name': 'Ada', ...}
シェルでは、tr でアルファベットを置き換え、ループでパディングします。
jwt_part() {
local s
s=$(printf '%s' "$1" | tr '_-' '/+')
while [ $(( ${#s} % 4 )) -ne 0 ]; do s="$s="; done
printf '%s' "$s" | base64 -d
}
jwt_part "$(echo "$TOKEN" | cut -d. -f2)"
トークンの中身を見るだけなら、JWT デコーダー に貼り付けてください。ブラウザ内で動作するので、本番環境のトークンがマシンの外に出ることはありません。
ペイロードが読める理由
エンコードは暗号化ではありません。Base64URL はバイト列の書き表し方を変えるだけで、JWT デコーダー は鍵なしでこれを元に戻します。署名付き JWT が保証するのは、署名された後にペイロードが変更されていないことです。ペイロードを隠すわけではありません。
エンコードにはサイズのコストもかかります。各クレームは Base64URL でエンコードすると 3 分の 1 大きくなり、トークン全体がリクエストのたびにヘッダーや Cookie で送られます。ペイロードは小さく保ちましょう。ユーザープロフィール全体ではなく、ID といくつかの短いクレームにとどめてください。
すべてのクレームは公開されているものとして扱ってください。ユーザー ID、ロール、有効期限は問題ありません。パスワード、API キー、トークンの所持者に見せるべきでない個人情報は不適切です。内容を秘匿する必要があるなら、暗号化された JWT(JWE)を使ってください。そのコンパクト形式は、ヘッダー、暗号化された鍵、IV、暗号文、認証タグの 5 つの Base64URL パートで構成されます。違いについて一般的には エンコードと暗号化の違い で解説しています。
署名の計算方法
署名の対象は header.payload という正確な ASCII テキスト、つまり 2 つの Base64URL 文字列とその間のドットです。HS256 の場合:
import hmac, hashlib
signing_input = f"{header}.{payload}".encode("ascii")
mac = hmac.new(b"demo-secret-do-not-use", signing_input, hashlib.sha256).digest()
hmac.compare_digest(mac, b64url_decode(signature)) # True
生の署名バイト列は、その後パディングなしの Base64URL でエンコードされます。長さはアルゴリズムによって異なります。
| アルゴリズム | 署名のバイト数 | Base64URL の文字数 |
|---|---|---|
| HS256 | 32 | 43 |
| ES256 | 64 | 86 |
| RS256(2048 ビット鍵) | 256 | 342 |
JWS の ES256 署名は、それぞれ 32 バイトの生の R || S 値です。一方、OpenSSL や多くの暗号ライブラリは DER エンコードされた ECDSA 署名を生成し、これは 30 で始まる可変長のデータです。両者の変換は「invalid signature」エラーのよくある原因です。
HS、RS、ES、EdDSA の鍵でトークンを作成・署名するには、JWT エンコーダー を使ってください。Base64 の生の HMAC については HMAC ツール をご覧ください。
よくあるミス
atobや標準の Base64 デコーダーを直接使う。-や_がたまたま含まれるかどうかによって、動くトークンもあれば例外になるトークンもあります。例のペイロードはatobでデコードできますが、署名はできません。- トークンの作成時にパディングを付ける。
=で終わるセグメントを拒否するライブラリもあります。取り除いてください。 - 検証の前にヘッダーやペイロードを再エンコードする。 署名の対象は元の Base64URL テキストです。JSON をパースして再びシリアライズすると、キーの順序や空白が変わり、署名が一致しなくなることがあります。必ず受け取ったままのセグメントに対して検証してください。
- デコードしたペイロードを信用する。 デコードは検証ではありません。想定したアルゴリズムで署名を確認し、意図しない限り
"alg":"none"(ヘッダーはeyJhbGciOiJub25lIiwidHlwIjoiSldUIn0)は決して受け付けないでください。 - トークン文字列を一意のキーとして使う。 32 バイトの署名には 256 ビットが必要で、43 文字には 258 ビットが入ります。最後の 2 ビットは使われないので、例では末尾の
kをl、m、nに置き換えても、寛容なデコーダーは同じバイト列を返します。署名の検証も通ります。セキュリティ上は無害ですが、異なる 2 つの文字列が同じトークンを表しうることになります。 - タイムスタンプをミリ秒として読む。
expとiatは秒単位です。JavaScript のDateに渡す前に 1000 を掛けてください。
よくある質問
JWT のペイロードは暗号化されていますか?
いいえ。通常の署名付き JWT(JWS)はペイロードを Base64URL でエンコードするだけなので、トークンを持っている人なら誰でも読めます。暗号化されたトークンには JWE を使い、パートは 3 つではなく 5 つになります。
JWT が = で終わらないのはなぜですか?
RFC 7515 がエンコード方式を、末尾の = をすべて取り除いた Base64URL と定めているからです。これにより、トークンをエスケープなしで URL やヘッダーに入れられます。デコーダーはパディングを戻すか、パディングのない入力を受け付ける必要があります。
3 つのパートは秘密鍵なしでデコードできますか?
できます。ヘッダーとペイロードはただの Base64URL です。鍵が必要なのは、署名を検証するときか、新しい署名を作るときだけです。JWT デコーダー は両方のパートを表示し、鍵を渡せば署名も検証します。
JWT がすべて eyJ で始まるのはなぜですか?
ヘッダーは {" で始まる JSON オブジェクトで、この 2 バイトは必ず eyJ にエンコードされるからです。このような接頭辞は よくある Base64 の接頭辞 にまとめています。