ToolDeck

JavaScript JWT デコード atob jose

·JavaScript Performance Engineer·レビュー担当Sophie Laurent·公開日

無料の JWTデコーダー をブラウザで直接使用 — インストール不要。

JWTデコーダー をオンラインで試す →

私がこれまで構築してきた認証フローはどれも、最終的に同じ状況に行き着きます。Cookie、ヘッダー、またはOAuthコールバックURLにJWTが存在し、その中身を読み取る必要が生じます。JavaScriptのJWTデコーダー にはnpmパッケージは不要です。トークンのヘッダーとペイロードは単なるBase64urlエンコードされたJSONであり、ブラウザとNode.jsの両方にデコードに必要なすべてが組み込まれています。このガイドでは、JWTの完全な JavaScriptテキストデコーダーパイプラインを解説します: トークンの分割、 base64url から標準Base64への正規化、UTF-8処理のための atob() TextDecoder、 Node.jsの Buffer.from()jose による署名検証、そして多くの開発者がハマりやすいよくある間違いについて説明します。素早くその場で確認したい場合は、代わりに オンラインJWTデコーダー をお試しください。すべての例は ES2020+ および Node.js 18+ を対象としています。

  • JWTを "." で分割する — インデックス0がヘッダー、インデックス1がペイロード、インデックス2が署名。
  • atob() はBase64をデコードするがLatin-1を返す(UTF-8ではない)。非ASCIIクレームにはTextDecoderまたはBuffer.from()を使用すること。
  • Buffer.from(segment, "base64url") はNode.jsでbase64urlをネイティブに処理する — 手動の文字置換は不要。
  • デコードは検証ではない。サーバーサイドで署名を確認せずにデコードしたJWTのクレームを信頼してはいけない。
  • joseライブラリは両方を行う:HS256/RS256/ES256の署名を検証し、1回の呼び出しでデコードされたペイロードを返す。

JWTデコードとは?

JSON Web Tokenはドットで区切られた3つのBase64urlエンコードされたセグメントで構成されています。最初の セグメントはヘッダー、2番目は実際に必要なクレームを含むペイロード、 3番目は暗号署名です。ヘッダーはトークン自体を記述する小さなJSONオブジェクトです。最も重要なフィールドは alg — 署名アルゴリズム(例:HS256, RS256, ES256)です。typ フィールドはほぼ常に "JWT"であり、 オプションの kid フィールドはトークンの署名に使用されたキーを識別します — アイデンティティプロバイダーがキーをローテーションし、複数の公開鍵を持つJWKSエンドポイントを公開する場合に重要です。

ペイロードはクレームを保持します。RFC 7519では7つの登録クレーム名が定義されています: sub (subject — 通常はユーザーID)、 iss (issuer — 認証サーバーURL)、 aud (audience — トークンが対象とするAPI)、 iat (発行時刻タイムスタンプ)、 exp (有効期限タイムスタンプ)、 nbf (有効開始タイムスタンプ)、そして jti (JWT ID — リプレイ攻撃防止に使用)です。すべてのタイムスタンプはミリ秒ではなくUnixエポック秒です。署名セグメントは生バイナリ — キー付きHMACダイジェストまたは非対称デジタル署名です。他のセグメントと同様にBase64urlエンコードされていますが、そのバイトはJSONではなく、人間が読める構造を持ちません。

実際には、JavaScriptでJWTをデコードする理由は3つあります。1つ目はデバッグ:OAuthフローやテスト環境からトークンを入手し、認証サーバーが発行すべきクレームと一致することを確認したい場合。2つ目はクライアントサイドでの表示目的でのユーザークレームの読み取り — 追加のAPIコールなしにトークンペイロードからログイン中のユーザーの名前、アバターURL、ロールバッジを表示する場合。3つ目はリフレッシュ試行前の有効期限確認: exp が次の60秒以内なら、次のAPIコールの前にサイレントリフレッシュをトリガーして、401レスポンスを待つのではなく先手を打ちます。

デコードはトークンが有効かどうか、または改ざんされていないかどうかを確認しません。それはHMACシークレットまたはRSA/ECDSA公開鍵を必要とする「検証」と呼ばれる別の操作です。誰でもJWTをデコードできます。正しいキーを持つ者だけが検証できます。この違いは、デコードされたクレームは表示されるが認証バックエンドチェックなしでは認可判断に信頼してはならない、クライアントサイドの認証フローを構築する多くの開発者をつまずかせます。

Before · json
After · json
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxMTYxMDAwMH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
// ヘッダー
{ "alg": "HS256" }

// ペイロード
{
  "sub": "usr_921f",
  "role": "admin",
  "iat": 1711610000
}

atob() + TextDecoder — ブラウザネイティブのJWTデコード

JWTをデコードするブラウザネイティブのパイプラインには4つのステップがあります。まず、トークン文字列を "." で分割して3つのセグメントを取得します。次に、base64urlセグメントを正規化します: - + に、 _ / に置換し、長さが4の倍数になるまで = でパディングします。3番目に、 atob() を呼び出してBase64をバイナリ文字列にデコードします。4番目に、 TextDecoder を使ってバイナリ文字列を適切なUTF-8に変換します。この最後のステップが重要なのは、 atob() がLatin-1を返すからです。マルチバイト文字 — 絵文字、CJKテキスト、Latin-1の範囲を超えたアクセント付き文字 — は、 JavaScriptテキストデコーダーステップなしでは文字化けします。

JavaScript — 最小限のJWTデコード
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxMTYxMDAwMH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";

function decodeJwtPayload(jwt) {
  const base64Url = jwt.split(".")[1];
  const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
  const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, "=");
  const binary = atob(padded);
  const bytes = Uint8Array.from(binary, ch => ch.charCodeAt(0));
  const json = new TextDecoder("utf-8").decode(bytes);
  return JSON.parse(json);
}

console.log(decodeJwtPayload(token));
// { sub: "usr_921f", role: "admin", iat: 1711610000 }

パディングのステップは見落とされやすいです。JWTはJWT仕様(RFC 7515)がパディングなしのbase64urlを定義しているため、Base64urlセグメントから末尾の = 文字を削除します。しかし一部のブラウザエンジンの atob() は、入力の長さが4で割り切れない場合に InvalidCharacterError をスローします。 padEnd() で防御的にパディングすることで、すべての環境でそのエッジケースを回避できます。ヘッダーとペイロードの両方を別々のオブジェクトにデコードする再利用可能なバージョンは以下のとおりです:

JavaScript — ヘッダーとペイロードのデコード
function decodeBase64Url(segment) {
  const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
  const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, "=");
  const binary = atob(padded);
  const bytes = Uint8Array.from(binary, ch => ch.charCodeAt(0));
  return new TextDecoder("utf-8").decode(bytes);
}

function decodeJwt(token) {
  const [headerB64, payloadB64] = token.split(".");
  return {
    header: JSON.parse(decodeBase64Url(headerB64)),
    payload: JSON.parse(decodeBase64Url(payloadB64)),
  };
}

const { header, payload } = decodeJwt(token);
console.log("Algorithm:", header.alg);   // "HS256"
console.log("Subject:", payload.sub);     // "usr_921f"
console.log("Role:", payload.role);       // "admin"

これらの2つの関数が揃ったら、ロジックをファイル間でコピー&ペーストするのではなく、共有ユーティリティモジュールに配置することをお勧めします。 src/lib/jwt.ts utils/jwt-decode.ts ファイルに型付きの返り値シェイプを持たせることで、コードベース全体で意図が明確になります。TypeScriptでは、 { header: JwtHeader; payload: JwtPayload } として返り値を型付けでき、 JwtHeader には alg typ、そしてオプションの kid JwtPayload はRFC 7519の登録クレームをカスタムクレーム用のインデックスシグネチャで拡張したものになります。 デコードロジックを一箇所に集中させることで、後でエラー処理(不正なセグメントのキャッチ)やテレメトリー(デコード失敗のログ記録)を追加したい場合も、更新箇所は1か所だけです。

注意:TextDecoder のステップこそが、このパイプラインを非ASCIIクレームに対して安全にするものです。これなしでは、 atob() はマルチバイトUTF-8シーケンスが文字をまたいで分割されたLatin-1文字列を返します。絵文字やCJKテキストの代わりにゴミが表示されます。必ず atob() の後に new TextDecoder("utf-8") を通してください。

マルチバイト文字を含むUTF-8 JWTクレームのデコード

JWTペイロードはbase64urlとしてエンコードされたUTF-8 JSONです。ほとんどのペイロードにはASCIIのみのユーザーIDやタイムスタンプが含まれているため、開発者は atob() がUTF-8ではなくLatin-1を返すことに気づきません。絵文字、日本語文字、キリル文字、またはU+00FFを超えるコードポイントがクレームに含まれた瞬間に問題が表面化します。 JavaScript decode UTF-8 パターンでは、まずバイナリ文字列をバイト配列に変換し、それから TextDecoder を通す必要があります。

JavaScript — 絵文字とCJK文字を含むJWTペイロードのUTF-8ラウンドトリップ
// 日本語名と絵文字を含むJWTペイロードのシミュレーション
const payloadObj = {
  sub: "usr_e821",
  display_name: "田中太郎",  // 日本語名のUTF-8エンコード例
  team: "Platform 🚀",
  region: "ap-northeast-1"
};

// エンコード: オブジェクト → JSON → UTF-8バイト → base64url
const jsonStr = JSON.stringify(payloadObj);
const utf8Bytes = new TextEncoder().encode(jsonStr);
const base64 = btoa(String.fromCharCode(...utf8Bytes))
  .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

// デコード: base64url → base64 → バイナリ文字列 → バイト → UTF-8文字列
const base64Std = base64.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(base64Std);
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
const decoded = new TextDecoder("utf-8").decode(bytes);
const result = JSON.parse(decoded);

console.log(result.display_name); // "田中太郎" — 正しく表示
console.log(result.team);         // "Platform 🚀" — 正しく表示

古いコードベースには、 decodeURIComponent をパーセントエンコーディングのトリックと組み合わせたレガシーのフォールバックパターンが存在します。この JavaScript decodeURIComponent アプローチは、各バイトをパーセント16進ペアとして再エンコードし、 decodeURIComponent がマルチバイトUTF-8シーケンスを再組み立てするため機能します:

JavaScript — UTF-8のdecodeURIComponentフォールバック
function decodeBase64UrlLegacy(segment) {
  const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
  const binary = atob(base64);
  // 各文字を%XX16進数に変換し、decodeURIComponentがUTF-8を再組み立て
  const utf8 = decodeURIComponent(
    binary.split("").map(c =>
      "%" + c.charCodeAt(0).toString(16).padStart(2, "0")
    ).join("")
  );
  return utf8;
}

// TextDecoderなしで非ASCIIクレームに対応
const payload = decodeBase64UrlLegacy(token.split(".")[1]);
console.log(JSON.parse(payload));
警告:古いJWTユーティリティスニペットで decodeURIComponent(escape(atob(segment))) パターンを見かけることがあります。 escape() 関数は非推奨で非標準です。 上記の TextDecoder アプローチに置き換えてください。 JavaScript unescapeデコーダーパターンも同様の問題があります:unescape() も非推奨です。どちらの関数も将来のJavaScriptエンジンから削除される可能性があります。

JWTデコードパイプライン — ステップリファレンス

ブラウザネイティブのJWTデコードパイプラインの各ステップ、使用するJavaScript APIと生成される値:

パラメータ / ステップ
説明
token.split(".")
string[]
JWTを[ヘッダー、ペイロード、署名]の3セグメントに分割する
base64url → base64
string replace
- を + に、_ を / に置換し、長さが4の倍数になるよう = でパディングする
atob(base64)
string
標準Base64文字列をバイナリ文字列(Latin-1)にデコードする
TextDecoder("utf-8")
TextDecoder
生バイトのUint8Arrayを正しいUTF-8文字列に変換する
JSON.parse()
object
結果のJSON文字列をJavaScriptオブジェクトにパースする

Node.jsの同等処理は、ステップ2から4を1回の呼び出しに集約します: Buffer.from(segment, "base64url").toString("utf-8") "base64url" エンコーディングオプションがアルファベット変換とパディングを内部で処理します。

Buffer.from() — JWTのNode.js文字列デコーダー

Node.jsにははるかにシンプルなパスがあります。 Buffer クラスは "base64url" エンコーディングを直接受け付けるため、手動の文字置換とパディングをスキップできます。これはサーバーサイドコードのJavaScript文字列デコーダーパスです。1行でJWTセグメントをUTF-8文字列に変換でき、追加のステップなしにマルチバイト文字を正しく処理します。

Node.js 18+ — BufferによるJWTデコード
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsIm9yZyI6ImFjbWUtY29ycCIsInJvbGUiOiJiaWxsaW5nIiwiaWF0IjoxNzExNjEwMDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";

function decodeJwt(jwt) {
  const segments = jwt.split(".");
  return {
    header: JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8")),
    payload: JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8")),
  };
}

const { header, payload } = decodeJwt(token);
console.log(header);
// { alg: "HS256" }
console.log(payload);
// { sub: "usr_921f", org: "acme-corp", role: "billing", iat: 1711610000 }

これは私がすべてのNode.jsプロジェクトで採用するアプローチです。より短く、より速く、すでにUTF-8を正しく処理しています。 TextDecoder も不要、文字置換も不要、パディング計算も不要です。 Buffer クラスはbase64urlアルファベットをネイティブに処理するJavaScript文字列デコーダーであり、文字置換に関連したバグのクラス全体を排除します。コードをブラウザとNode.jsの両方で実行する必要がある場合は、一番下のFAQでランタイムを実行時に検出するアイソモーフィックなラッパー関数を参照してください。

ミドルウェアとAPIルートハンドラーで最もよく使用するパターンである、共通のJWTクレームを抽出してタイムスタンプを読みやすい日付に変換する、より完全な例を以下に示します:

Node.js — 実用的なJWTクレーム抽出
function inspectToken(token) {
  const segments = token.split(".");
  if (segments.length !== 3) {
    throw new Error("Not a valid JWT — expected 3 dot-separated segments");
  }

  const header = JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8"));
  const payload = JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8"));

  const inspection = {
    algorithm: header.alg,
    tokenType: header.typ || "JWT",
    subject: payload.sub,
    issuer: payload.iss || "(not set)",
    audience: payload.aud || "(not set)",
    issuedAt: payload.iat ? new Date(payload.iat * 1000).toISOString() : "(not set)",
    expiresAt: payload.exp ? new Date(payload.exp * 1000).toISOString() : "(never)",
    isExpired: payload.exp ? payload.exp < Math.floor(Date.now() / 1000) : false,
    customClaims: Object.keys(payload).filter(
      k => !["sub", "iss", "aud", "iat", "exp", "nbf", "jti"].includes(k)
    ),
  };

  return inspection;
}

console.log(inspectToken(process.env.ACCESS_TOKEN));
// {
//   algorithm: "RS256",
//   tokenType: "JWT",
//   subject: "usr_921f",
//   issuer: "https://auth.internal",
//   audience: "billing-api",
//   issuedAt: "2026-03-10T14:00:00.000Z",
//   expiresAt: "2026-03-10T15:00:00.000Z",
//   isExpired: true,
//   customClaims: ["role", "scope", "org"]
// }

本番Node.jsサービスでは、 Buffer.from() デコードパターンは3つの繰り返しケースで登場します。1つ目はリクエストログミドルウェア:認証サーバーへの追加ネットワークラウンドトリップなしに userId org をすべての構造化ログエントリに付加するために、受信した Authorization ヘッダーをデコードします。2つ目はデバッグ:テストアサーションを書く前に、正しいスコープが発行されたことを確認するために、開発中にデコードされたトークンクレームをコンソールに出力します。3つ目はAPIゲートウェイでのプロアクティブなトークンリフレッシュです。トークンを上流に転送してダウンストリームサービスがリクエスト途中でトークンが期限切れになって401を返すのを待つのではなく、ゲートウェイはエッジでトークンをデコードして exp クレームを読み取り、有効期限が次の30秒以内であればリフレッシュをトリガーします。これにより、再現が難しくデバッグに苦労する一時的な認証障害のクラス全体が排除されます。

注意:"base64url" エンコーディングはNode.js 15.7.0で追加されました。 Node.js 14以前を使用している場合は、 Buffer.from(segment.replace(/-/g, "+").replace(/_/g, "/"), "base64") にフォールバックしてください。同様に動作しますが、手動の文字置換が必要です。

ファイルおよびAPIレスポンスからのJWTデコード

2つのシナリオが常に発生します。1つ目はローカルファイルからJWTを読み取る場合:開発中に保存されたトークン、テストフィクスチャ、またはインシデント後の事後分析のためにダンプされたファイル。2つ目はHTTPレスポンスからJWTを抽出する場合(通常はOAuthトークンレスポンスボディの access_token フィールドや Authorization ヘッダー)。不正なトークン、切り捨てられたファイル、ネットワークエラーは日常茶飯事なので、両方にエラー処理が必要です。先週有効だったトークンにはコピー&ペーストからの末尾の空白や改行が含まれている可能性があります。認証サーバーがエラーページを返した場合、レスポンスボディはJSONではなくHTMLになる可能性があります。

ファイルからのJWT読み取り(Node.js)

Node.js — エラー処理付きのファイルからのJWTデコード
import { readFileSync } from "node:fs";

function decodeJwtFromFile(filePath) {
  const raw = readFileSync(filePath, "utf-8").trim();
  const segments = raw.split(".");

  if (segments.length !== 3) {
    throw new Error(`Invalid JWT: expected 3 segments, got ${segments.length}`);
  }

  try {
    return {
      header: JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8")),
      payload: JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8")),
    };
  } catch (err) {
    throw new Error(`Failed to decode JWT from ${filePath}: ${err.message}`);
  }
}

try {
  const { header, payload } = decodeJwtFromFile("./test-fixtures/access-token.txt");
  console.log("Algorithm:", header.alg);
  console.log("Expires:", new Date(payload.exp * 1000).toISOString());
} catch (err) {
  console.error(err.message);
}

APIレスポンスからのJWT抽出(fetch)

JavaScript — APIレスポンスからのJWTデコード
async function fetchAndDecodeToken(loginUrl, credentials) {
  const response = await fetch(loginUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(credentials),
  });

  if (!response.ok) {
    throw new Error(`Login failed: ${response.status} ${response.statusText}`);
  }

  const { access_token } = await response.json();
  if (!access_token || access_token.split(".").length !== 3) {
    throw new Error("Response does not contain a valid JWT");
  }

  const payload = access_token.split(".")[1];
  const json = Buffer.from(payload, "base64url").toString("utf-8");
  return JSON.parse(json);
}

// 使用例
try {
  const claims = await fetchAndDecodeToken(
    "https://auth.internal/oauth/token",
    { username: "deploy-bot", password: process.env.DEPLOY_TOKEN }
  );
  console.log("Token subject:", claims.sub);
  console.log("Token scopes:", claims.scope);
  console.log("Expires at:", new Date(claims.exp * 1000).toISOString());
} catch (err) {
  console.error("Token decode error:", err.message);
}

コマンドラインでのJWTデコード

スクリプトを書かずにターミナルからトークンを確認したいだけの場合があります。 Node.jsはほとんどの開発者のマシンで利用可能なので、ワンライナーがうまく機能します。 jq が整形表示を担当します。

bash — ターミナルからJWTペイロードをデコード
# Node.jsワンライナーでJWTペイロードをデコード
echo "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiJ9.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" \
  | cut -d. -f2 \
  | node -e "process.stdin.on('data', d => console.log(JSON.parse(Buffer.from(d.toString().trim(), 'base64url').toString('utf-8'))))"

# jqで整形表示
echo "$JWT_TOKEN" | cut -d. -f2 \
  | node -e "process.stdin.on('data', d => process.stdout.write(Buffer.from(d.toString().trim(), 'base64url').toString('utf-8')))" \
  | jq .

# ヘッダーとペイロードの両方をデコード
echo "$JWT_TOKEN" | node -e "
  process.stdin.on('data', d => {
    const parts = d.toString().trim().split('.');
    console.log('Header:', JSON.parse(Buffer.from(parts[0], 'base64url').toString()));
    console.log('Payload:', JSON.parse(Buffer.from(parts[1], 'base64url').toString()));
  });
"

Node.jsなしの純粋なbashを好む場合は、 tr でbase64url文字を修正した後に base64 -d にパイプしてください:

bash — Node.jsなしの純粋なbash JWTデコード
# 純粋なbash: Node.jsなしでJWTペイロードをデコード
echo "$JWT_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .

# macOSバリアント(base64 -D を使用)
echo "$JWT_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -D 2>/dev/null | jq .

ターミナルをまったく使わずに素早く視覚的に確認するには、トークンを ToolDeck JWTデコーダー に貼り付けると、3つのセグメントすべてをカラーコードされたクレームラベルと有効期限ステータスで並べて表示します。

jose — 1つのライブラリで検証とデコードを実現

本番認証ミドルウェアには、デコードだけでなく署名検証が必要です。 jose ライブラリが最適な選択肢です。Node.jsとブラウザの両方(Web Crypto API経由)で動作し、HS256、RS256、ES256、EdDSA、JWE(暗号化トークン)をサポートし、ネイティブ依存関係はゼロです。 npm install jose でインストールしてください。

JavaScript — jose: HS256トークンの検証
import * as jose from "jose";

const secret = new TextEncoder().encode("k8s-webhook-signing-secret-2026");
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInNjb3BlIjoiYmlsbGluZzpyZWFkIiwiaWF0IjoxNzExNjEwMDAwLCJleHAiOjE3MTE2MTM2MDB9.abc123";

try {
  const { payload, protectedHeader } = await jose.jwtVerify(token, secret);
  console.log("Algorithm:", protectedHeader.alg); // "HS256"
  console.log("Subject:", payload.sub);            // "usr_921f"
  console.log("Scope:", payload.scope);            // "billing:read"
} catch (err) {
  if (err.code === "ERR_JWT_EXPIRED") {
    console.error("Token expired at:", err.payload.exp);
  } else {
    console.error("Verification failed:", err.message);
  }
}
JavaScript — jose: JWKSエンドポイントによるRS256検証
import * as jose from "jose";

// アイデンティティプロバイダーから公開鍵セットを取得
const jwks = jose.createRemoteJWKSet(
  new URL("https://auth.internal/.well-known/jwks.json")
);

const token = req.headers.authorization?.split(" ")[1];
if (!token) {
  return res.status(401).json({ error: "Missing token" });
}

try {
  const { payload } = await jose.jwtVerify(token, jwks, {
    issuer: "https://auth.internal",
    audience: "billing-api",
  });
  // payload.sub、payload.scopeなどが検証済みになった
  req.userId = payload.sub;
} catch (err) {
  return res.status(401).json({ error: "Invalid token" });
}

jose と古い jsonwebtoken パッケージの選択については、主な違いはランタイムスコープです。jsonwebtoken はNode.js専用 — crypto ビルトインに依存しており、ブラウザ向けにバンドルできません。jose は完全なアイソモーフィック:すべてのモダンブラウザ、Node.js 16+、Deno、Bun、Cloudflare Workersで利用可能なWeb Crypto APIを使用します。認証ロジックがNext.jsミドルウェアファイル(Edge Runtimeで実行)、Cloudflare Worker、またはサーバーとクライアントコードの両方からインポートされる共有ユーティリティに存在する場合、 jose はネイティブ依存関係がゼロでビルドステップなしにインストールできるため正しい選択です。jsonwebtoken は、広範な署名ヘルパーのエコシステムが必要で、エッジ環境でコードを実行する予定がない純粋なNode.jsサーバーアプリケーションには引き続き合理的です。2026年のグリーンフィールドプロジェクトでは、古いAPIを好む特定の理由がない限り、デフォルトで jose を選択してください。

検証なしのデコードのみが必要な場合、joseは jose.decodeJwt(token) でペイロードを返し、 jose.decodeProtectedHeader(token) でヘッダーを返す便利関数を提供しています。これらはBase64urlデコードを内部で行う便利関数です。しかし、joseを採用する主な理由は、デコードと検証を組み合わせずに行うべきケースがほとんどないからです。クライアントサイドでトークンクレームからユーザー自身の表示名やアバターURLを表示するだけなら、デコードのみで十分です。サーバーサイドでは常に検証してください。署名を確認せずにアクセス制御の決定のためにJWTクレームをデコードする本番システムを見たことがありますが、それはJWTフォーマットを理解した攻撃者への開いた扉です。

JavaScript — デコードのみのシナリオでのjose.decodeJwt
import * as jose from "jose";

// デコードのみ: シークレット不要、検証なし
const payload = jose.decodeJwt(token);
console.log(payload.sub);   // "usr_921f"
console.log(payload.scope); // "billing:read"

const header = jose.decodeProtectedHeader(token);
console.log(header.alg);    // "HS256"
console.log(header.typ);    // "JWT"

// 検証なしの有効期限確認(クライアントサイド表示)
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
  console.log("Token has expired — redirect to login");
}

シンタックスハイライト付きのターミナル出力

Node.js CLIツールでのデバッグやインシデント対応時に、色付きの出力は実際に大きな差をもたらします。 chalk ライブラリと JSON.stringify を組み合わせれば実現できます。 npm install chalk でインストールしてください。

Node.js — カラー付きJWTデコード出力
import chalk from "chalk";

function printJwt(token) {
  const segments = token.split(".");
  if (segments.length !== 3) {
    console.error(chalk.red("Invalid JWT: expected 3 segments"));
    return;
  }

  const header = JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8"));
  const payload = JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8"));

  console.log(chalk.bold.cyan("\n=== JWT Header ==="));
  console.log(chalk.gray(JSON.stringify(header, null, 2)));

  console.log(chalk.bold.green("\n=== JWT Payload ==="));
  console.log(chalk.gray(JSON.stringify(payload, null, 2)));

  // 有効期限ステータスをハイライト
  if (payload.exp) {
    const expiresAt = new Date(payload.exp * 1000);
    const isExpired = expiresAt < new Date();
    console.log(
      chalk.bold("\nExpires:"),
      isExpired
        ? chalk.red(`EXPIRED at ${expiresAt.toISOString()}`)
        : chalk.green(`Valid until ${expiresAt.toISOString()}`)
    );
  }

  console.log(chalk.dim("\nSignature: " + segments[2].substring(0, 20) + "..."));
}

printJwt(process.argv[2]);
// 実行: node jwt-debug.mjs "eyJhbGci..."
注意:色付き出力はターミナル専用です。JWTクレームをログファイル、APIレスポンス、またはデータベースフィールドに書き込む際にchalkを使用しないでください。ANSIエスケープコードが非ターミナルコンテキストでゴミとして表示されます。

大きなログファイルからのJWT処理

現代のAPIインフラはNDJSON形式の構造化アクセスログを出力します — 各行にリクエストパス、レスポンスステータス、レイテンシー、デコードまたは生の Authorization ヘッダーを含むJSONオブジェクトが1つあります。トラフィックの多いサービスではこれらのファイルは急速に大きくなります:1分間に10,000リクエストを処理するゲートウェイは1日に1,400万以上のログエントリを生成します。セキュリティおよびコンプライアンスのユースケースでは、これらのファイルを後から定期的にスキャンする必要があります — 侵害されたサービスアカウントが行ったすべてのリクエストの特定(インシデント後分析)、特定のユーザーのトークンがデータアクセスウィンドウ前に期限切れになったことの確認(コンプライアンス監査)、またはメンテナンスウィンドウ中に機密エンドポイントにアクセスしたすべてのサブジェクトセットの抽出。単一のログファイルが数ギガバイトを超える可能性があるため、 readFileSync でメモリに読み込むことは実用的ではありません。Node.jsのreadlineストリームは一定のメモリオーバーヘッドで1行ずつファイルを処理し、標準的な開発者ラップトップで任意の大きさのログをスキャンできます。

個々のJWTは数キロバイト程度なので「ファイルが大きすぎてメモリに収まらない」問題は発生しません。実際に起きるのは、大きなアクセスログや監査証跡でJWTトークンをスキャンし、各トークンをデコードして特定のクレームを抽出するシナリオです。Node.jsストリームはファイル全体を読み込まずにこれを処理します。

Node.js — NDJSONログをストリームして埋め込みJWTをデコード
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

async function scanLogsForExpiredTokens(logPath) {
  const fileStream = createReadStream(logPath, { encoding: "utf-8" });
  const rl = createInterface({ input: fileStream, crlfDelay: Infinity });

  let lineCount = 0;
  let expiredCount = 0;
  const nowSeconds = Math.floor(Date.now() / 1000);

  for await (const line of rl) {
    lineCount++;
    try {
      const entry = JSON.parse(line);
      if (!entry.authorization_token) continue;

      const segments = entry.authorization_token.split(".");
      if (segments.length !== 3) continue;

      const payload = JSON.parse(
        Buffer.from(segments[1], "base64url").toString("utf-8")
      );

      if (payload.exp && payload.exp < nowSeconds) {
        expiredCount++;
        const expDate = new Date(payload.exp * 1000).toISOString();
        console.log("Line " + lineCount + ": expired token for " + payload.sub + ", exp=" + expDate);
      }
    } catch {
      // 不正な行をスキップ
    }
  }

  console.log(`\nScanned ${lineCount} lines, found ${expiredCount} expired tokens`);
}

scanLogsForExpiredTokens("./logs/api-access-2026-03.ndjson");
Node.js — ログストリームからユニークなJWTサブジェクトを抽出
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

async function extractUniqueSubjects(logPath) {
  const rl = createInterface({
    input: createReadStream(logPath, { encoding: "utf-8" }),
    crlfDelay: Infinity,
  });

  const subjects = new Set();
  const jwtRegex = /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;

  for await (const line of rl) {
    const matches = line.match(jwtRegex);
    if (!matches) continue;

    for (const token of matches) {
      try {
        const payload = JSON.parse(
          Buffer.from(token.split(".")[1], "base64url").toString("utf-8")
        );
        if (payload.sub) subjects.add(payload.sub);
      } catch {
        // 有効なJWTではない
      }
    }
  }

  console.log(`Found ${subjects.size} unique subjects:`);
  for (const sub of subjects) console.log(`  ${sub}`);
}

extractUniqueSubjects("./logs/gateway-2026-03.log");
注意:ログファイルが50MBを超えたらストリーミングに切り替えてください。500MBのNDJSONファイルを readFileSync で読み込むとメモリを占有してGCポーズが発生します。 readline アプローチは一定のメモリ使用量で1行ずつ処理します。

よくある間違い

非ASCIIクレームにTextDecoderなしでatob()を使用する

問題: atob() はLatin-1文字列を返します。マルチバイトUTF-8文字(絵文字、CJK、アクセント付き文字)は文字をまたいで分割され、文字化けした出力が生成されます。

修正: atob() の出力を Uint8Array に変換し、new TextDecoder('utf-8') に通してください。

Before · JavaScript
After · JavaScript
// 非ASCIIペイロードクレームで壊れる
const payload = JSON.parse(atob(token.split(".")[1]));
// display_name が "田中太郎" の代わりに "ç°ä¸­å¤ªé\x83\x8E" と表示される
const binary = atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"));
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
const payload = JSON.parse(new TextDecoder("utf-8").decode(bytes));
// display_name が "田中太郎" と正しく表示される
base64urlからbase64への文字置換を忘れる

問題: atob() が "InvalidCharacterError" をスローします。base64urlは + と / の代わりに - と _ を使用するためです。

修正: atob() を呼び出す前に - を + に、_ を / に置換してください。Node.jsの Buffer.from() は 'base64url' を使用すれば自動的に処理します。

Before · JavaScript
After · JavaScript
// スロー: InvalidCharacterError: String contains an invalid character
const payload = atob(token.split(".")[1]);
const segment = token.split(".")[1];
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
const payload = atob(base64); // これで動作する
署名検証なしにデコードされたJWTクレームを信頼する

問題: 誰でも任意のペイロードを持つJWTを作成できます。デコードはデータを読み取るだけ — トークンが認証サーバーによって発行されたことを証明しません。

修正: サーバーサイドでは、常に jose.jwtVerify() または jsonwebtoken.verify() を使用して署名を検証してください。デコードのみはクライアントサイドでのユーザークレームの表示には許容されます。

Before · JavaScript
After · JavaScript
// 危険: デコードされているが検証されていない
const claims = JSON.parse(atob(token.split(".")[1]));
if (claims.role === "admin") {
  grantAdminAccess(); // 攻撃者がこれを偽造できる
}
import * as jose from "jose";
const { payload } = await jose.jwtVerify(token, secretKey);
if (payload.role === "admin") {
  grantAdminAccess(); // 安全 — 署名が検証済み
}
expをDate.now()と1000で割らずに比較する

問題: JWT の exp はエポックからの秒単位ですが、Date.now() はミリ秒を返します。ミリ秒のタイムスタンプが1000倍大きいため、比較では常にトークンが有効と判定されます。

修正: expと比較する前に Date.now() を1000で割って切り捨ててください。

Before · JavaScript
After · JavaScript
// バグ: Date.now() はミリ秒、exp は秒
if (payload.exp > Date.now()) {
  console.log("Token is valid"); // 常にtrue — 間違い!
}
const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.exp > nowSeconds) {
  console.log("Token is valid"); // 正しい比較
}

JWTデコードメソッド — クイック比較

メソッド
実行環境
UTF-8対応
署名検証
カスタム型
インストール
atob() + TextDecoder
ブラウザ
N/A(読み取り専用)
不要
Buffer.from()
Node.js
N/A(読み取り専用)
不要
decodeURIComponent()
ブラウザ(レガシー)
N/A(読み取り専用)
不要
jose
両方
✓ (JWS/JWE)
npm install
jsonwebtoken
Node.js
npm install
jwt-decode
両方
N/A
npm install

ユーザーにクレームを表示するだけのブラウザサイドのデコードには atob() + TextDecoder を使用してください。 Node.jsスクリプトとCLIツールには Buffer.from() を使用してください。署名の検証が必要になった瞬間、つまりサーバーサイドの認証ミドルウェアでは jose を使用してください。 jwt-decode パッケージは、ブラウザでのデコードのみに1関数のAPIが必要な場合の軽量代替品です。コードを書かずに素早く視覚的に確認するには、トークンを JWTデコーダーツール に貼り付けてください。

よくある質問

ライブラリなしでJavaScriptのJWTトークンをデコードするには?

トークンを "." で分割し、2番目のセグメント(ペイロード)を取得します。- を + に、_ を / に置換してbase64urlエンコーディングを正規化し、= 文字でパディングを追加してから、atob() を呼び出し、続けて TextDecoder で UTF-8 の JSON 文字列を取得します。結果を JSON.parse() に通すとクレームオブジェクトが得られます。npmパッケージは不要です。このアプローチはすべてのモダンブラウザおよび Node.js 18+ で動作します。ヘッダーも読みたい場合は、最初のセグメントに同じデコード手順を適用してください。ただし、署名検証なしに生データを取得するため、サーバーサイドで署名を検証しない限り、結果は表示専用として扱ってください。

JavaScript
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const payload = token.split(".")[1];
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const json = atob(base64);
const claims = JSON.parse(json);
console.log(claims);
// { sub: "usr_921f", role: "admin" }

JWTデコードにおける atob() と Buffer.from() の違いは?

atob() は標準Base64をLatin-1バイナリ文字列にデコードするブラウザAPIです。base64urlエンコーディングを直接理解しないため、先に - と _ の文字を置換する必要があります。Buffer.from(segment, "base64url") はNode.jsのAPIで、base64urlアルファベットをネイティブに処理し、.toString("utf-8") を呼び出せるBufferを返します。ブラウザでは atob()、Node.jsでは Buffer.from() を使用してください。3つ目の選択肢として、歴史的によく使われた decodeURIComponent パーセントエンコーディングトリックがありますが、古いスニペットでは非推奨の escape() 関数に依存するパターンがあるため、新しいコードでは避けるべきです。両方の環境で動作するアイソモーフィックなコードには、typeof Buffer !== "undefined" でチェックして分岐してください。

JavaScript
// ブラウザ
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));

// Node.js
const json2 = Buffer.from(payload, "base64url").toString("utf-8");

atob() が非ASCII JWTクレームで文字化けするのはなぜ?

atob() は各文字が1バイトに対応するLatin-1文字列を返します。マルチバイトUTF-8シーケンス(絵文字、CJK文字、Latin-1範囲外のアクセント付き文字)は複数の文字にまたがって分割され、文字化けした出力が生成されます。修正方法は、バイナリ文字列をまず Uint8Array に変換し、その配列を new TextDecoder("utf-8").decode() に渡すことです。TextDecoder APIはマルチバイトシーケンスを正しく再組み立てします。この問題はJWTペイロードのほとんどがASCIIのユーザーID、タイムスタンプ、ロール名のみを含むため、開発中は気づきにくいですが、クレームに非ASCIIの表示名やローカライズされた文字列が含まれた瞬間に表面化します。アプリケーションの進化とともにクレームが変わることがあるため、現在のペイロードがASCIIのみであっても、新しいコードでは常に TextDecoder を使うパスを選択してください。

JavaScript
// 壊れている: atobはLatin-1を返すためマルチバイト文字が文字化けする
const broken = atob(base64); // "ð\x9F\x8E\x89" ではなく絵文字

// 修正済み: バイト配列に変換してからTextDecoderを使用
const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
const fixed = new TextDecoder("utf-8").decode(bytes);

JavaScriptでJWT署名を検証できますか?

デコードと検証は異なる操作です。デコードはペイロードを読み取るだけで、暗号化はされていません。検証はシークレット(HMAC)または公開鍵(RSA/ECDSA)に対して署名を確認します。joseライブラリはWeb Crypto APIを介してブラウザとNode.jsの両方でこれをサポートします。jsonwebtokenパッケージはNode.jsのみで動作します。サーバーサイドで署名を検証せずにデコードされたクレームを信頼してはいけません。クライアントサイドでは、ユーザーの表示名や有効期限をJWTから読み取るためにデコードするのは許容されますが、ユーザーが特定のロールや権限を持つかどうかのチェックなど、アクセス制御の決定は、検証後のサーバーサイドコードで行われなければなりません。JWTフォーマットを理解した攻撃者は任意のクレームを持つトークンを偽造でき、クライアントサイドのチェックは通過してしまいます。

JavaScript
import * as jose from "jose";

const secret = new TextEncoder().encode("your-256-bit-secret");
const { payload } = await jose.jwtVerify(token, secret);
console.log(payload.sub); // verified claims

JavaScriptでJWTの有効期限が切れているか確認するには?

ペイロードをデコードして exp クレームを読み取ります。これは秒単位のUnixタイムスタンプです。Math.floor(Date.now() / 1000) を使って現在時刻と比較します。現在時刻が exp より大きければ、トークンは期限切れです。注意: exp の値はエポックからのミリ秒ではなく秒なので、Date.now() を1000で割ることが必要です。実際には、小さなクロックスキューバッファを組み込むことをお勧めします。厳密に過去かどうかではなく、トークンが次の30秒以内に期限切れになるかどうかを確認することで、デコード時にはまだ技術的に有効でも次のダウンストリームAPIコールの処理時に期限切れになるエッジケースを防ぎます。また、exp が完全に存在しない場合(トークンが決して期限切れにならないことを意味する)も処理してください。

JavaScript
function isTokenExpired(token) {
  const payload = JSON.parse(
    atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))
  );
  const nowSeconds = Math.floor(Date.now() / 1000);
  return payload.exp < nowSeconds;
}

console.log(isTokenExpired(myToken)); // true or false

Node.jsとブラウザの両方で動作するアイソモーフィックなJWTデコードコードを書くには?

globalThis.Buffer の存在を確認します。存在する場合はNode.js環境であり、Buffer.from(segment, "base64url").toString("utf-8") を使用できます。存在しない場合はブラウザ環境であり、atob() と TextDecoder のアプローチを使用する必要があります。このチェックを単一の decodeBase64Url 関数にラップして、どこでも使用してください。これはNext.jsサーバーコンポーネントとブラウザのReactコンポーネントの両方からインポートされるモノレポパッケージの共有コードに最も重要です。環境検出を一箇所に集中させることで、ランタイムが変わる場合(例えばDenoが完全なBufferサポートを追加したり、新しいエッジランタイムが別のコードパスを必要とする場合)も1か所だけ更新すれば済みます。

JavaScript
function decodeBase64Url(segment) {
  if (typeof Buffer !== "undefined") {
    return Buffer.from(segment, "base64url").toString("utf-8");
  }
  const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
  const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
  return new TextDecoder("utf-8").decode(bytes);
}

関連ツール

他の言語でも利用可能:Python
MW
Marcus WebbJavaScript Performance Engineer

Marcus specialises in JavaScript performance, build tooling, and the inner workings of the V8 engine. He has spent years profiling and optimising React applications, working on bundler configurations, and squeezing every millisecond out of critical rendering paths. He writes about Core Web Vitals, JavaScript memory management, and the tools developers reach for when performance really matters.

SL
Sophie Laurent技術レビュアー

Sophie is a full-stack developer focused on TypeScript across the entire stack — from React frontends to Express and Fastify backends. She has a particular interest in type-safe API design, runtime validation, and the patterns that make large JavaScript codebases stay manageable. She writes about TypeScript idioms, Node.js internals, and the ever-evolving JavaScript module ecosystem.