base64.b64decode() にJWTのセグメントを渡すと、Pythonでは binascii.Error: Invalid base64-encoded string が返ってくることがあります。-_ が混ざっているか、末尾の = がないことが原因ですが、「+- に置き換えるだけ」という直し方では足りない場合があります。Base64とBase64URLで出力が変わる場所は3か所あり、それぞれ別の対処が必要です。

1. 文字置換:+と/が-と_になる

Base64のアルファベット64文字のうち、62番目と63番目の文字だけが変わります。

インデックス Base64 Base64URL 0〜61 A–Z, a–z, 0–9 同じ 62 + - 63 / _

エンコード対象のバイト列に、この2文字に対応する6ビットパターン(111110 または 111111)が含まれているときだけ出力が変わります。含まれなければBase64とBase64URLの出力は完全に同じです。

import base64
data = b'\xfb\xef\xbe'  # バイナリ: 11111011 11101111 10111110

print(base64.b64encode(data))            # b'++++' 
print(base64.urlsafe_b64encode(data))    # b'----'

\xfb\xef\xbe の3バイトは6ビット単位で切り出すとすべて 111110(インデックス62)になるため、Base64では ++++、Base64URLでは ---- になります。変換は対称なので、デコード側では逆に -+_/ と置き換えてから標準のデコード関数に渡せます。

2. パディング:=ありと=なし

Base64は3バイトを4文字に変換します。入力が3の倍数バイトでない場合、出力を4の倍数文字にするため末尾に = を1つか2つ付けます。

入力 1バイト → 出力 2文字 + "==" (パディング2個)
入力 2バイト → 出力 3文字 + "="  (パディング1個)
入力 3バイト → 出力 4文字       (パディングなし)

RFC 4648のSection 5(Base64url)は、受け取り側がデータの全長を事前に知っている場合に限り、パディングを省略可能と定めています。JWTはこの条件を満たすと判断し、3つのセグメント(ヘッダー・ペイロード・署名)すべてで = を省略します。

JWTのセグメントは「Base64URLかつパディングなし」です。Pythonの base64.urlsafe_b64decode() はパディングを要求するため、そのまま渡すとエラーになります。

import base64, json

segment = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'

# パディングを補ってからデコードする
padding = (4 - len(segment) % 4) % 4
decoded = base64.urlsafe_b64decode(segment + '=' * padding)
print(json.loads(decoded))  # {'alg': 'HS256', 'typ': 'JWT'}

パディング補完の公式 (4 - len(s) % 4) % 4 は、len % 4 == 0 のときに4を足さないための二重mod演算です。len % 4 が0なら (4 - 0) % 4 = 0、2なら (4 - 2) % 4 = 2、3なら (4 - 3) % 4 = 1 と、必要なパディング数が得られます。

3. URLへの埋め込み:=が%3Dになる

標準Base64の出力をURLのクエリパラメータに埋め込むと、= はパーセントエンコードされ %3D になります。

# 標準Base64のクエリ文字列埋め込み(そのまま渡した場合)
?token=abc+def/ghi==

# URLエンコード後(URLとして正しいが冗長)
?token=abc%2Bdef%2Fghi%3D%3D

# Base64URL(URLセーフ、パディングなし)
?token=abc-def_ghi

+ はクエリ文字列内でスペースとして解釈されるパーサーが存在し、= はキーと値の区切りとして処理されることがあります。3番目の形式が最もクリーンです。これがBase64URLが生まれた理由です。

どの形式が使われているか

用途 形式 パディング JWT(ヘッダー・ペイロード・署名) Base64URL なし OAuth 2.0 PKCE(code_challenge) Base64URL なし Data URI(data:image/png;base64,...) 標準Base64 あり MIME(メール添付) 標準Base64 あり URLクエリパラメータに直接埋め込む場合 Base64URL なし推奨 ファイル名として使う場合 Base64URL なし推奨

デコード時に「どの形式で渡ってきたのか」を先に確認します。-_ が含まれていればBase64URL、+/ が含まれていれば標準Base64です。末尾の = の有無はパディング設定によるため、形式の判定材料にはなりません。

デコードの手順まとめ

形式が混在している可能性がある場合は、以下の順序で変換します。

import base64

def decode_base64_any(s: str) -> bytes:
    # 1. Base64URL → Base64 の文字置換
    s = s.replace('-', '+').replace('_', '/')
    # 2. パディング補完
    s += '=' * ((4 - len(s) % 4) % 4)
    # 3. デコード
    return base64.b64decode(s)

JavaScriptでは atob() が標準Base64しか受け付けないため、Base64URLを渡す前に同じ変換が必要です。

function decodeBase64Any(s) {
  s = s.replace(/-/g, '+').replace(/_/g, '/');
  while (s.length % 4 !== 0) s += '=';
  return atob(s);
}

Node.js 16以降は 'base64url' エンコーディングが追加されたため、Buffer.from(s, 'base64url') でパディングなし・文字置換済みのBase64URLをそのままデコードできます。ただしこれはNode.js専用の仕様なので、ブラウザや他の言語では同じコードは動きません。

実際にBase64/Base64URLを切り替えながらエンコード・デコードを試したい場合は、以下のツールで確認できます。