ファイルを選択
  1. base64hub
  2. Base64 を学ぶ
  3. JWT と Base64
解説

JWT での Base64URL の使われ方:トークンを手作業でデコードする

JWT は、3 つの Base64URL 文字列をドットでつないだものです。最初の 2 つは誰でもデコードでき、それらを守っているのは署名だけです。各パートの作られ方と読み方を説明します。

トークンの構造

以下は、デモ用のシークレット 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}
署名43HMAC-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 の文字数
HS2563243
ES2566486
RS256(2048 ビット鍵)256342

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 の接頭辞 にまとめています。

続けて読む