DevToolBoxとは
DevToolBox は、開発者が日常的に必要とするツールをブラウザ上で手軽に使えるようにまとめたサービスです。インストール不要、登録不要で、85種類以上のツールをすぐに利用できます。
本記事では単なるツール紹介にとどまらず、その中でも特に技術的に奥が深い JWTデコーダー と Base64エンコード/デコード を取り上げ、内部で何が起きているのかをPythonで実際に手を動かして検証します。「JWTは暗号化なのか」「なぜBase64には=パディングが必要なのか」「URLセーフBase64はどこが違うのか」といった、ツールを使うだけでは見えない部分を掘り下げます。
JWTの構造を実際にデコードしてみる
header.payload.signature の3部構成
JWT(JSON Web Token)
は、.(ピリオド)で区切られた3つの部分から構成される文字列です。
<Header>.<Payload>.<Signature>
- Header: 署名アルゴリズム(
alg)とトークン種別(typ)を表すJSON - Payload: 実際のクレーム(
sub、iatなど任意のキー・バリュー)を表すJSON - Signature: HeaderとPayloadを連結した文字列に対する署名またはMAC
HeaderとPayloadは、それぞれJSON文字列をBase64URLエンコードしただけのものです。ここが最も誤解されやすい点ですが、JWTは暗号化ではありません。HeaderとPayloadは秘密鍵・共通鍵を一切使わずに誰でもデコードでき、中身がそのまま読めます。保護されているのはSignature(署名)だけで、これは「Payloadが改ざんされていないこと」を保証するものであり、「Payloadの中身を隠すこと」を保証するものではありません。
検証:HS256トークンを生成し、秘密鍵なしでPayloadを読む
実際にHS256(HMAC-SHA256)で署名したJWTを生成し、署名検証に使う秘密鍵を一切使わずにHeaderとPayloadをデコードできることを確認します。
import base64, json, hmac, hashlib
def b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode().rstrip("=")
def b64url_decode(s: str) -> bytes:
padding = "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s + padding)
header = {"alg": "HS256", "typ": "JWT"}
payload = {"sub": "1234567890", "name": "Taro Yamada", "admin": True, "iat": 1752800000}
secret = b"my-super-secret-key" # 署名者だけが知っている共通鍵
header_b64 = b64url_encode(json.dumps(header, separators=(",", ":")).encode())
payload_b64 = b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
signing_input = f"{header_b64}.{payload_b64}".encode()
signature = hmac.new(secret, signing_input, hashlib.sha256).digest()
sig_b64 = b64url_encode(signature)
jwt_token = f"{header_b64}.{payload_b64}.{sig_b64}"
print("JWT:", jwt_token)
# --- ここから先は秘密鍵を一切使わない、誰にでもできる操作 ---
parts = jwt_token.split(".")
decoded_header = json.loads(b64url_decode(parts[0]))
decoded_payload = json.loads(b64url_decode(parts[1]))
print("Decoded header :", decoded_header)
print("Decoded payload:", decoded_payload)
実行結果:
JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlRhcm8gWWFtYWRhIiwiYWRtaW4iOnRydWUsImlhdCI6MTc1MjgwMDAwMH0.dc0_QysBXj61aImxzt6fgqe4KldrdX8ogAJz-6z6QJA
Decoded header : {'alg': 'HS256', 'typ': 'JWT'}
Decoded payload: {'sub': '1234567890', 'name': 'Taro Yamada', 'admin': True, 'iat': 1752800000}
秘密鍵secretを一度も参照せずに、{"admin": true}まで含めたPayloadの中身が完全に読めています。JWTをローカルストレージやログに保存する際、機密情報をPayloadに入れてはいけないというベストプラクティスは、この性質から直接導かれます。
DevToolBox JWTデコーダー
でも同じことがブラウザ上で即座に確認できます。
エッジケース:RS256/HS256 アルゴリズム混同攻撃
JWT検証の実装ミスとして有名なものに「algヘッダをそのまま信用してしまう」ことに起因する脆弱性があります。よく知られる alg: none 攻撃(署名検証自体を無効化させる手口)については、OAuth 2.0 / OpenID Connect のIDトークン検証を扱った
OAuth 2.0とOpenID Connectの仕組み:認可と認証の基礎
で、PyJWTを使った実装込みで詳しく検証済みです。ここでは、それとは別のもう一つの古典的な実装バグであるRS256→HS256アルゴリズム混同攻撃を取り上げます。
RS256(RSA署名)を想定して作られたサーバーが、同じ検証関数を使い回して「HS256も受け付ける」実装になっていると、次のような事故が起こり得ます。
- サーバーはRSA鍵ペアを持ち、IDトークンをRS256(秘密鍵で署名・公開鍵で検証)で発行する
- 公開鍵はJWKSエンドポイントなどで誰でも取得できるように公開されている
- 検証コードが
verify(token, key, algorithms=["RS256", "HS256"])のように、algがHS256のときは同じkey変数をHMACの共通鍵として使い回してしまう - 攻撃者は秘密鍵を持っていなくても、公開鍵のバイト列そのものをHMAC共通鍵として使い、
alg: HS256のトークンを自分で署名できてしまう
Pythonで実際にこの攻撃を再現し、脆弱な検証関数と安全な検証関数の挙動の違いを確認します。
import base64, json, hmac, hashlib
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes, serialization
def b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode().rstrip("=")
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
# サーバーの本物のRSA鍵ペア(秘密鍵はサーバーだけが保持、公開鍵はJWKSで公開)
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
public_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
# 正規のRS256トークン(秘密鍵で署名)
header_rs = {"alg": "RS256", "typ": "JWT"}
payload = {"sub": "user-42", "admin": False}
h_b64 = b64url_encode(json.dumps(header_rs, separators=(",", ":")).encode())
p_b64 = b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
signing_input = f"{h_b64}.{p_b64}".encode()
rsa_sig = private_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
legit_token = f"{h_b64}.{p_b64}.{b64url_encode(rsa_sig)}"
# 脆弱な検証関数:alg=HS256のとき、同じkeyをHMAC共通鍵として使い回す
def vulnerable_verify(token: str, key_material):
h_b64, p_b64, s_b64 = token.split(".")
header = json.loads(b64url_decode(h_b64))
signing_input = f"{h_b64}.{p_b64}".encode()
alg = header["alg"]
if alg == "RS256":
pub = serialization.load_pem_public_key(key_material)
pub.verify(b64url_decode(s_b64), signing_input, padding.PKCS1v15(), hashes.SHA256())
elif alg == "HS256":
expected = hmac.new(key_material, signing_input, hashlib.sha256).digest()
if not hmac.compare_digest(expected, b64url_decode(s_b64)):
raise ValueError("bad HMAC signature")
else:
raise ValueError("unsupported alg")
return json.loads(b64url_decode(p_b64))
print("verify(legit RS256 token) ->", vulnerable_verify(legit_token, public_pem))
# 攻撃者:秘密鍵は持たないが、公開鍵(PEM文字列)をHMAC共通鍵として使いHS256トークンを偽造
forged_header = {"alg": "HS256", "typ": "JWT"}
forged_payload = {"sub": "user-42", "admin": True}
fh_b64 = b64url_encode(json.dumps(forged_header, separators=(",", ":")).encode())
fp_b64 = b64url_encode(json.dumps(forged_payload, separators=(",", ":")).encode())
forged_signing_input = f"{fh_b64}.{fp_b64}".encode()
forged_sig = hmac.new(public_pem, forged_signing_input, hashlib.sha256).digest()
forged_token = f"{fh_b64}.{fp_b64}.{b64url_encode(forged_sig)}"
print("verify(forged HS256 token) ->", vulnerable_verify(forged_token, public_pem))
# 安全な検証関数:許可するアルゴリズムを呼び出し側が固定し、トークンのalg宣言を信用しない
def safe_verify(token: str, public_key_material, allowed_algs=("RS256",)):
h_b64, p_b64, s_b64 = token.split(".")
header = json.loads(b64url_decode(h_b64))
if header["alg"] not in allowed_algs:
raise ValueError(f"algorithm {header['alg']!r} not permitted; expected one of {allowed_algs}")
pub = serialization.load_pem_public_key(public_key_material)
pub.verify(b64url_decode(s_b64), f"{h_b64}.{p_b64}".encode(), padding.PKCS1v15(), hashes.SHA256())
return json.loads(b64url_decode(p_b64))
try:
safe_verify(forged_token, public_pem, allowed_algs=("RS256",))
except Exception as e:
print("safe_verify(forged token) -> raised:", type(e).__name__, e)
実行結果:
verify(legit RS256 token) -> {'sub': 'user-42', 'admin': False}
verify(forged HS256 token) -> {'sub': 'user-42', 'admin': True}
safe_verify(forged token) -> raised: ValueError algorithm 'HS256' not permitted; expected one of ('RS256',)
攻撃者は秘密鍵を一切持たないまま、admin: trueに書き換えたトークンをvulnerable_verifyに通すことに成功しています。原因は「検証側が受け付けるアルゴリズムをトークン自身のalg宣言に委ねている」ことです。safe_verifyのようにサーバー側のコードで許可アルゴリズムを固定し、トークンのalg宣言を信用しないことで、この攻撃は確実に拒否されます。alg: none攻撃も含め、「algはヘッダに書いてある自己申告に過ぎず、攻撃者が自由に書き換えられる」という一点がJWT実装の最大の注意点です。
JWTを試したい場合は DevToolBox JWTデコーダー でトークンの中身を、 JWTジェネレーター でHS256トークンの発行をブラウザ上で試せます。
Base64エンコーディングのビット演算
3バイト→4文字のビットパッキング
Base64は8ビット単位(バイト)のバイナリデータを、64種類の印字可能文字だけで表現するためのエンコーディングです。\(2^6 = 64\) なので、6ビットのまとまりが1つのBase64文字に対応します。バイト(8ビット)と文字(6ビット)は最小公倍数の24ビット(=3バイト=4文字)で揃うため、Base64は入力を3バイトずつ区切って処理します。
- 3バイト(24ビット)を読み込む
- 24ビットを先頭から6ビットずつ4つに区切り直す(バイトの境界とは一致しない)
- 各6ビット値(0〜63)を、
A-Z(0-25)→a-z(26-51)→0-9(52-61)→+(62)→/(63)のアルファベット表に対応させる
文字列 "Man" を例に、実際にPythonでビット単位まで分解して検証します。
import base64
data = b"Man"
bits = "".join(f"{byte:08b}" for byte in data)
print("入力バイト:", list(data), "->", bits, f"({len(bits)}ビット)")
sextets = [bits[i:i+6] for i in range(0, len(bits), 6)]
print("6ビットずつに再分割:", sextets)
indices = [int(s, 2) for s in sextets]
print("6ビット値(0-63):", indices)
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
chars = [alphabet[i] for i in indices]
print("対応するBase64文字:", chars, "->", "".join(chars))
print("標準ライブラリでの検証:", base64.b64encode(data))
実行結果:
入力バイト: [77, 97, 110] -> 010011010110000101101110 (24ビット)
6ビットずつに再分割: ['010011', '010110', '000101', '101110']
6ビット値(0-63): [19, 22, 5, 46]
対応するBase64文字: ['T', 'W', 'F', 'u'] -> TWFu
標準ライブラリでの検証: b'TWFu'
自前でビット分解した結果が base64.b64encode の出力と完全に一致しました。下図は、このビットパッキングを可視化したものです。バイト境界(点線)と6ビットのセクステット境界(色分け)が一致しない様子が見て取れます。

なぜパディング(=)が必要か
入力が3バイトの倍数でない場合、最後のグループは24ビットに満たず、6ビットのまとまりが作れません。この端数を埋めるために=パディングが使われます。
import base64
for s in [b"M", b"Ma", b"Man"]:
enc = base64.b64encode(s)
print(f"{s!r} ({len(s)}バイト={len(s)*8}ビット) -> {enc.decode()} (パディング数: {enc.decode().count('=')})")
実行結果:
b'M' (1バイト=8ビット) -> TQ== (パディング数: 2)
b'Ma' (2バイト=16ビット) -> TWE= (パディング数: 1)
b'Man' (3バイト=24ビット) -> TWFu (パディング数: 0)
- 1バイト(8ビット)の入力は、6ビットずつに割ると1つ目のセクステットしか完全に埋まらず、2つ目は2ビットしか残らない → 4ビットのゼロ埋めをして1文字分にし、残りの2文字分は中身がないことを示す
==を付与 - 2バイト(16ビット)の入力は、2つ目のセクステットまで埋まり3つ目が4ビットしか残らない → 2ビットのゼロ埋めで1文字分にし、残り1文字分を
=で埋める - 3バイト(24ビット)はちょうど4セクステットに割り切れるためパディング不要
=は「デコード時にここまでが実データで、ここから先は端数調整用のゼロ埋めだった」と伝えるための区切り文字であり、これがないと受信側は元のバイト数を復元できません。
エッジケース:URLセーフBase64(+/ vs -_)
標準のBase64アルファベットは62番目に+、63番目に/を使います。ところがこの2文字はURLやファイルパスで特別な意味を持ちます(+はスペースを表すことがあり、/はパス区切り文字です)。そのため、URLやファイル名にBase64文字列を埋め込む用途(JWTの各セグメントもこれです)では、62番目と63番目だけを-と_に置き換えたURLセーフBase64(RFC 4648 §5)が使われます。
import base64
# 62番目・63番目のインデックスが出るように選んだバイト列
raw = bytes([0xFB, 0xFF, 0xBF])
print("入力バイト:", list(raw))
print("標準Base64 :", base64.b64encode(raw).decode())
print("URLセーフB64:", base64.urlsafe_b64encode(raw).decode())
実行結果:
入力バイト: [251, 255, 191]
標準Base64 : +/+/
URLセーフB64: -_-_
同じ入力バイト列でも、標準Base64では+や/が現れるのに対し、URLセーフ版では-と_に置き換わっています。デコード時のアルファベット表さえ合わせれば相互変換は機械的ですが、エンコード方式を取り違えると+や/を含む文字列がURLの一部として壊れる(+がスペースにデコードされたり、/がパスの区切りと誤認されたりする)ため、用途に応じて明示的に使い分ける必要があります。実際、JWTの各セグメントはBase64URLエンコードであり、標準Base64ではありません。
Base64を試す際は DevToolBox Base64エンコード/デコード 、画像の変換には 画像Base64変換 、URL自体のエンコードには URLエンコード/デコード が便利です。
ツールカテゴリ
フォーマット・整形
JSON、SQL、XML、CSS、JavaScript、HTMLなどのコードをワンクリックで整形・ミニファイできます。Markdownプレビューや、SVGの最適化にも対応しています。
- JSON整形・検証 - JSONの整形、バリデーション、ミニファイ
- SQLフォーマッター - SQLクエリの自動整形
- CSSミニファイ/整形 - CSSの圧縮・展開
- YAMLバリデーター - YAML構文チェック
エンコード・デコード
Base64、URL、HTMLエンティティなどの変換を即座に行えます。画像のBase64変換も対応しています(ビット単位の仕組みは上記で解説した通りです)。
- Base64エンコード/デコード - テキストやファイルのBase64変換
- URLエンコード/デコード - URLの安全なエンコード処理
- 画像Base64変換 - 画像をBase64文字列に変換
テキスト処理
正規表現テスト、テキスト差分比較、文字数カウントなど、テキスト操作に関するツールを揃えています。正規表現エンジンの内部動作(バックトラッキングとReDoS)は Python正規表現実践ガイド で詳しく解説しています。
- 正規表現テスター - リアルタイムで正規表現をテスト
- テキスト差分比較 - 2つのテキストの差分を視覚的に確認
- Markdownテーブル生成 - GUIでMarkdownテーブルを作成
データ変換
YAML⇔JSON、CSV⇔JSON、TOML⇔JSONなど、異なるフォーマット間の相互変換に対応しています。
- YAML⇔JSON変換 - YAMLとJSONの相互変換
- CSV⇔JSON変換 - CSVとJSONの相互変換
- JSON→TypeScript変換 - JSONからTypeScript型定義を自動生成
生成ツール
ハッシュ、UUID、パスワード、QRコードなど、開発で必要になる様々なデータを生成します。ハッシュ関数(SHA-256)とHMACの内部構成は SHA-256とHMACの理論とPython実装 で数値実験込みに解説しています。
- ハッシュ生成 - MD5、SHA-1、SHA-256などのハッシュ値を計算
- UUID生成 - UUIDv4をワンクリック生成
- パスワード生成 - 安全なパスワードを自動生成
- QRコード生成 - テキストやURLからQRコードを作成
CSSデザインツール
CSSのグラデーション、Box Shadow、Flexbox、Grid、アニメーションなどをGUIで作成し、コードをコピーできます。
- CSSグラデーション生成 - グラデーションをビジュアルに作成
- Flexboxプレイグラウンド - Flexboxレイアウトを試す
- CSSグリッドジェネレーター - CSS Gridレイアウトを生成
- CSSアニメーション生成 - アニメーションをGUIで作成
開発支援
JWT解析、Cron式パーサー、HTTPステータスコード一覧など、開発を加速するツールを提供します。JWTの仕組みは上記で詳しく解説した通りです。
- JWTデコーダー - JWTトークンの中身を解析
- Cron式パーサー - Cron式を人間が読める形式に変換
- .gitignore生成 - プロジェクトに合わせた.gitignoreを生成
- OGPプレビュー - Open Graphタグのプレビュー確認
特徴
- 完全無料・登録不要 - すべてのツールをブラウザだけで利用可能
- オフライン対応 - PWAとしてインストールすれば、オフラインでも利用可能
- 多言語対応 - 日本語・英語に対応
- プライバシー重視 - データはすべてブラウザ内で処理され、サーバーに送信されません
まとめ
JWTは「暗号化」ではなく「Base64URLエンコード+署名」であり、Payloadは誰でも読めること、保護されているのは改ざん検知のための署名だけであることを実際のデコード結果で確認しました。また、algヘッダを検証側が信用してしまうと、alg: none攻撃だけでなくRS256/HS256アルゴリズム混同攻撃のように公開鍵を悪用した署名偽造も成立し得ることを、実際にPythonで攻撃を成功させることで示しました。Base64についても、3バイト→4文字のビットパッキングと=パディングの必要性、そして+/と-_が入れ替わるURLセーフ変体の違いを、標準ライブラリの出力と突き合わせながら検証しました。
ぜひ DevToolBox をブックマークして、日々の開発にお役立てください。
関連記事
- OAuth 2.0とOpenID Connectの仕組み:認可と認証の基礎
- JWT形式のIDトークンにおける
alg: none攻撃、JWKS、aud/iss検証をPyJWTで詳しく検証しています。 - SHA-256とHMACの理論とPython実装 - JWTのHS256署名やハッシュ生成ツールが内部で使うSHA-256とHMACの構成を解説しています。
- Python正規表現実践ガイド
- 正規表現テスターの背後にある
reモジュールとReDoSの内部動作を解説しています。 - CalcBox - 暮らしに役立つ計算ツール集 | ポモドーロタイマー