0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JWT のペイロードを atob で読んだら、中村さんは文字化けして、小林さんは例外になった

0
Posted at

背景

自分のツールサイトに、JWT の中身をブラウザだけで読むページを置いています。
https://hashitosystem.com/tools/jwt/
ログインまわりの不具合を調べるとき、トークンに何が入っているかを見たくて作りました。
最初は atob(token.split('.')[1]) の1行で済むと思っていたのですが、日本語の名前を入れたトークンで試したら、化けるものと例外で落ちるものが出ました。
どちらも同じ原因の裏表だったので、残しておきます。

JWT の形

JWT(JSON Web Token)は、ログイン情報などを JSON にして持ち運ぶためのトークンだ。
ヘッダー.ペイロード.署名 の3つを . でつないだ文字列で、ペイロードに sub(ユーザーID)や name、iat(発行日時)などが入ります。
仕様は RFC 7519 です。
https://www.rfc-editor.org/rfc/rfc7519

各部分の符号化は、普通の Base64 ではなく Base64URL だ。
Base64 は、バイト列を英数字と + / の64文字で表す方式で、長さを4の倍数にそろえるために末尾に = を足します。
Base64URL はそれを URL に載せやすくした変種で、+ の代わりに -、/ の代わりに _ を使い、末尾の = は省きます。
JWT の元になっている RFC 7515 の2章に、この定義があります。
https://www.rfc-editor.org/rfc/rfc7515#section-2

atob だけで読んでみる

下記は Node.js でペイロードを作って、atob で読み戻してみたものです。
Buffer.from(...).toString('base64url') が、JWT と同じ Base64URL でペイロード部分を作る処理になっています。

// jwt-atob.js : JWT のペイロード部分を作って atob で読む
for (const name of ['中村', '小林']) {
  const seg = Buffer.from(JSON.stringify({ sub: '42', name, iat: 1700000000 })).toString('base64url');
  console.log(seg);
  try {
    console.log(atob(seg));
  } catch (e) {
    console.log(e.name);
  }
}

Node.js v24.11.1 での結果が下記です。

eyJzdWIiOiI0MiIsIm5hbWUiOiLkuK3mnZEiLCJpYXQiOjE3MDAwMDAwMDB9
{"sub":"42","name":"中æ","iat":1700000000}
eyJzdWIiOiI0MiIsIm5hbWUiOiLlsI_mnpciLCJpYXQiOjE3MDAwMDAwMDB9
InvalidCharacterError

中村さんのほうは読めたものの、名前が 中æ のように化けています(端末に出ない制御文字も混じっています)。
小林さんのほうは、2行目の途中に _ が入っていて、atob が例外を投げました。
_ が出るかどうかは中身のバイト列しだいなので、「テストでは通ったのに本番の誰かのトークンで落ちる」が起きます…。

2つの原因

1つ目は、atob が Base64 用で、Base64URL の - と _ を知らないことだ。
atob が使っている「forgiving-base64 decode」の定義は、使える文字を英数字と + / に限っています。
https://infra.spec.whatwg.org/#forgiving-base64-decode
ちなみに末尾の = が無いのは許してくれるので(atob('YQ') は a になりました)、パディングより先に文字の置き換えが要ります。

2つ目は、atob の戻り値が「1文字=1バイト」の文字列であることだ。
JSON の中の日本語は UTF-8 で、1文字が3バイトになっています。
atob はその3バイトを3文字としてそのまま返すので、UTF-8 として組み立て直さないと化けます。

正しく読む

下記が、ツールで使っている読み方と同じ手順です。
引数の str は、JWT を . で分けたうちの1つ(ヘッダーかペイロード)です。

// Base64URL の1部分 → UTF-8 の文字列
function base64UrlDecode(str) {
  let s = str.replace(/-/g, '+').replace(/_/g, '/');
  const pad = s.length % 4;
  if (pad === 2) s += '==';
  else if (pad === 3) s += '=';
  else if (pad === 1) throw new Error('長さが不正です');
  const bin = atob(s);
  const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
  return new TextDecoder('utf-8').decode(bytes);
}

const seg = 'eyJzdWIiOiI0MiIsIm5hbWUiOiLlsI_mnpciLCJpYXQiOjE3MDAwMDAwMDB9';
console.log(JSON.parse(base64UrlDecode(seg)));

- と _ を + と / に戻し、= を補ってから atob しています。
atob の結果は1文字ずつ charCodeAt で数値にして Uint8Array に詰め、TextDecoder で UTF-8 として読みます。
実行結果が下記です。

{ sub: '42', name: '小林', iat: 1700000000 }

例外だった小林さんのトークンも、化けずに 小林 で戻りました。
= を補う部分は atob だけなら無くても動くのですが、長さが4で割って1余る文字列は Base64 として成り立たないので、そこで弾く意味で残しています。
Node.js だけで使うなら Buffer.from(str, 'base64url').toString('utf8') の1行でも同じ結果になります。
ブラウザでも同じコードで動くように、ツールでは atob と TextDecoder の組み合わせにしています。

注意: 読めることと、信じてよいことは別

ここでやっているのは、中身を見るための復号だけで、署名の検証はしていません。
ペイロードは誰でも書き換えて符号化し直せるので、アプリ側でユーザーの権限などを判断するときは、必ずサーバー側で署名を検証したうえで使ってください。

JWT を手で読むときは、atob の1行で済ませず、- と _ の置き換えと UTF-8 への組み立て直しの2つを必ず入れてください。


本記事はAI補助で執筆した、個人開発の紹介記事です。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?