【図解】JWTとは?ヘッダー・ペイロード・署名のしくみと、Entra IDのトークンの中身を初心者向けに解説

ここまでの記事で、JWT という言葉が何度も出てきました。クライアントクレデンシャルの記事ではアプリが証明書で署名する JWT として、リフレッシュトークンの記事では Entra ID が出すアクセストークンとして登場しています。この記事では、その JWT の中身を、RFC の例を実際にデコードしたり署名を計算し直したりしながら、図にしてみました。

この記事でわかること
  1. JWT は「ヘッダー.ペイロード.署名」の3つを「.」でつないだ文字列
  2. 中身は Base64URL で、暗号ではない。だれでも読める
  3. 署名があるので、書き換えるとばれる。署名には共通鍵と公開鍵の2つの方式がある
  4. Entra ID が JWT に署名し、API が公開鍵で確かめるまでの流れ
  5. Entra ID のアクセストークンに入っているクレーム

まずはたとえで:透明な袋に入った、印鑑つきの書類

JWT(JSON Web Token、「ジョット」と読みます)は、透明な袋に入った、印鑑つきの書類のようなものです。袋は透明なので、中身はだれでも読めます。ただ、書類には発行元の印が押してあり、中身を書き換えると印と合わなくなります。印を押せるのは、印鑑(鍵)を持っている発行元だけです。

中身はだれでも読める書き換えると、ばれる印を作れるのは発行元だけ透明な袋に入った書類JWT の中身は暗号ではない。Base64URL を戻せば、だれでも読める書き換えると印と合わない署名は、中身から計算した値。1文字でも変わると、検証で合わなくなる鍵を持つ発行元が印を押す印(署名)を作るには鍵が要る。受け取る側は、その印が本物かを確かめてから中身を信じるJWT(署名つき)は「透明な袋に入った、印鑑つきの書類」
図1 JWT は「透明な袋に入った、印鑑つきの書類」

大事なのは、JWT は中身を隠すものではなく、中身が本物で書き換えられていないことを示すもの、という点です。この記事ではまず、この「読める」と「書き換えるとばれる」を、RFC の例で確かめます。

どこで使われるか

JWT は、RFC 7519 で決められた形式です。RFC 7519 は JWT を「スペースの限られた場所向けの、コンパクトなクレーム(情報)の表し方」としています。小さな文字列なので、HTTP のヘッダーや URL に入れて運べます。

アクセストークンOAuth 2.0Entra IDJWTアプリJWTAPIAPI が中身を確かめて、応じるかを決めるID トークンOpenID ConnectEntra IDJWTアプリアプリが、サインインした利用者を知るclient_assertionアプリの証明アプリJWTEntra IDアプリが自分を証明する(証明書の方式)OAuth 2.0 はトークンの形式を決めていない。Entra ID は JWT を使う
図2 JWT が出てくる場所

これまでの記事に出てきたものでは、次の3つが JWT です。

  • アクセストークン:Entra ID が出し、アプリが API に見せるトークンです。API は中身を確かめて、応じるかを決めます。
  • ID トークン:OpenID Connect で、サインインした利用者の情報をアプリに伝えるトークンです(OpenID Connect の記事でくわしく見ます)。
  • client_assertion:アプリが証明書で自分を証明するときに、自分の秘密鍵で署名して送る JWT です(クライアントクレデンシャルの記事の図11)。

なお、OAuth 2.0(RFC 6749)は、アクセストークンの形式を決めていません。どんな形式にするかは認可サーバ次第で、Entra ID は JWT を使っています。

基本の形(RFC):3つの部分を「.」でつなぐ

RFC 7519 に載っている JWT の例です。RFC では、見やすさのために「.」の前後で改行していますが、実際は1行の文字列です。

JWT の例(RFC 7519 の 3.1)

eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9
.
eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ
.
dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
eyJ0eXAiOiJKV1Qi…ヘッダーeyJpc3MiOiJqb2Ui…ペイロードdBjftJeZ4CVP…署名..Base64URL を戻すBase64URL を戻す戻すとバイト列{“typ”:”JWT”, ”alg”:”HS256″}どう署名したか{“iss”:”joe”, ”exp”:1300819380, ”http://example.com/is_root”:true}中身(クレーム)32 バイトの値ヘッダーとペイロードから鍵で計算した(HS256 の場合)署名する対象:ヘッダー(Base64URL)+「.」+ペイロード(Base64URL)この文字列が1文字でも変わると、署名は合わなくなるヘッダー.ペイロード.署名 の3つを「.」でつなぐ(RFC 7519 の例)
図3 JWT の3つの部分(RFC 7519 の例)

1つ目がヘッダー、2つ目がペイロード(中身)、3つ目が署名です。ヘッダーとペイロードは、JSON という形式で書いた文字列を Base64URL という形式に変えたものです。署名は、ヘッダーとペイロードを「.」でつないだ文字列から、鍵を使って計算した値です。

Base64URL は暗号ではない

Base64URL は、データを英数字と一部の記号だけの文字列にする変換です。ふつうの Base64 から、URL で特別な意味を持つ記号を置き換え、末尾の = を付けないようにしたものです。

Base64Base64URL(JWT で使う)+62番目の文字–62番目の文字/63番目の文字_63番目の文字=末尾の埋め文字付けない末尾の埋め文字URL やヘッダーにそのまま入れられるように、記号を置き換えたもの暗号ではない。鍵がなくても、だれでも元に戻せるJWT が本物かどうかを示すのは、Base64URL ではなく署名
図4 Base64 と Base64URL

変換のしかたは決まっていて、鍵も使いません。実際に、図3の JWT を Python で元に戻してみます。

JWT を3つに分けて、Base64URL を戻すスクリプト

import base64

def b64url_decode(s):
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

jwt = ("eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9"
       ".eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ"
       ".dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")

header, payload, signature = jwt.split(".")
print(b64url_decode(header).decode())
print(b64url_decode(payload).decode())
print(len(b64url_decode(signature)), "bytes")

実行結果

$ python3 decode.py
{"typ":"JWT",
 "alg":"HS256"}
{"iss":"joe",
 "exp":1300819380,
 "http://example.com/is_root":true}
32 bytes

鍵がなくても、ヘッダーとペイロードはそのまま読めました。署名は32バイトの値です。ヘッダーの alg にある HS256 は「HMAC と SHA-256 で署名した」という意味で、SHA-256 の出力が32バイトなので、署名も32バイトになります。なお、この例の JSON には改行が入っていますが、これは RFC の例に合わせたもので、JSON として読めれば改行の有無は関係ありません。

ヘッダー:どう署名したか

ヘッダーには、この JWT をどう扱えばよいかが入ります。

名前意味
alg署名の方式(アルゴリズム)。必ず入れる(RFC 7515)。例:HS256・RS256・ES256
typこの文字列の種類。入れる場合は JWT にするのが推奨(RFC 7519)
kidどの鍵で署名したかを示すヒント(RFC 7515)。鍵を入れ替えたことを受け取る側に伝えるのにも使う

ペイロード:クレーム(中身)

ペイロードに入る1つ1つの情報を「クレーム」と呼びます。RFC 7519 は、よく使うクレームの名前を「登録済みクレーム」として決めています。

誰が出した?iss発行者誰について?sub主体(利用者など)誰あて?aud受け取る相手いつ?exp・nbf・iat期限・開始・発行どれ?jtiこの JWT の ID時間使える期間iat発行したnbfここから使えるexpここから使えない時刻は、1970-01-01 00:00:00(UTC)からの秒数(NumericDate)例:exp の 1300819380 は、2011-03-22 18:43:00(UTC)どれも必須ではない。何を使うかは、使う側の仕様で決める
図5 登録済みクレームと、時刻の意味
クレーム意味(RFC 7519)
iss発行者。この JWT を出した者
sub主体。この JWT が何について書かれているか(利用者など)
aud受け取る相手。この JWT が誰あてか
exp期限。この時刻以降は受け付けてはならない
nbf開始。この時刻より前は受け付けてはならない
iat発行した時刻
jtiこの JWT だけの ID

RFC 7519 では、これらはどれも必須ではありません。どれを使い、どれを必ず確かめるかは、JWT を使う側の仕様で決めます。また、登録済み以外の名前も自由に使えます。図3の例の http://example.com/is_root のように、ほかと重ならない名前(URL の形など)にしておくのがよいとされています。時刻は、1970年1月1日0時(UTC)からの秒数で表します。図3の exp の 1300819380 は、計算すると2011年3月22日18時43分(UTC)です。

署名:書き換えるとばれる

署名の対象は、Base64URL にしたヘッダーと、「.」と、Base64URL にしたペイロードをつないだ文字列です(RFC 7515 でいう JWS Signing Input)。そのため、ヘッダーかペイロードが1文字でも変わると、計算し直した署名は元の署名と合わなくなります。

これも実際に確かめてみます。RFC 7515 の付録には、図3の JWT を作ったときの共通鍵が載っています。その鍵で署名を計算し直し、元の署名と比べます。続けて、ペイロードの joe を bob に書き換えたもので同じことをします。

署名を計算し直して比べるスクリプト(鍵は RFC 7515 の付録 A.1 のもの)

import base64, hashlib, hmac

def b64url_encode(b):
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

def b64url_decode(s):
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

key = b64url_decode("AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow")
header  = "eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9"
payload = "eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ"
signature = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"

def sign(h, p):
    return b64url_encode(hmac.new(key, (h + "." + p).encode(), hashlib.sha256).digest())

print("original:", sign(header, payload) == signature)

forged = b64url_encode(b64url_decode(payload).replace(b"joe", b"bob"))
print("tampered:", sign(header, forged) == signature)

実行結果

$ python3 verify.py
original: True
tampered: False

元の JWT は署名が一致し(True)、1か所書き換えたものは一致しませんでした(False)。書き換えた人が署名も作り直せば一致しますが、それには鍵が要ります。鍵を持たない人には、署名を作り直せません。

署名の2つの方式:共通鍵と公開鍵

いまの例の HS256 は、共通鍵(両方が同じ鍵を持つ方式)の HMAC を使った署名でした。JWT の署名には、もうひとつ、公開鍵の方式があります(鍵のしくみは暗号の基礎の記事を参照)。

HS256 など:共通鍵(HMAC)RS256・ES256 など:公開鍵の方式発行する側受け取る側共通鍵同じ共通鍵JWT同じ鍵で作り、同じ鍵で確かめる受け取る側も印を作れてしまう鍵は公開できない。両方で秘密にする発行する側受け取る側秘密鍵公開鍵JWT秘密鍵で署名し、公開鍵で検証する受け取る側は印を作れない公開鍵は配ってよい。受け取り手が多くても安全Entra ID のトークンは、公開鍵の方式(RS256 など)で署名される
図6 署名の2つの方式
alg方式(RFC 7518)鍵
HS256HMAC と SHA-256共通鍵。発行する側と受け取る側が同じ鍵を持つ。鍵は256ビット以上
RS256RSA(PKCS #1 v1.5)と SHA-256秘密鍵で署名し、公開鍵で検証する
PS256RSA(PSS)と SHA-256同上
ES256ECDSA(P-256 曲線)と SHA-256同上
none署名なしなし。既定では受け付けてはならない

共通鍵の方式では、確かめる側も同じ鍵を持つので、確かめる側も JWT を作れてしまいます。受け取る側が自社の1つのシステムだけ、のような場面に向いています。公開鍵の方式では、確かめる側は公開鍵しか持たないので、JWT を作れません。公開鍵は配ってよいので、受け取る側がたくさんあっても安全です。Entra ID のように、多くの API にトークンを確かめてもらう発行者は、公開鍵の方式を使います。

表の最後の none は「署名なし」です。RFC 7518 は、署名なしの JWT を既定では受け付けてはならないとしています。受け取る側が確かめるときの注意点は、次の記事(JWT の検証)でくわしく見ます。

JWS と JWE:署名つきと暗号化

RFC 7519 では、JWT は JWS(JSON Web Signature、署名つき)か JWE(JSON Web Encryption、暗号化)のどちらかの形で表します。ここまで見てきた3つの部分の形は JWS です。

JWS(署名つき):3つの部分ヘッダー.ペイロード(そのまま).署名中身は読める。改ざんは署名で分かるJWE(暗号化):5つの部分ヘッダー.暗号化した鍵.初期化ベクトル.暗号文暗号文.認証タグ中身は暗号化されていて、鍵がないと読めないよく見るアクセストークンは JWS。ペイロードに秘密を入れない
図7 JWS(3つの部分)と JWE(5つの部分)

JWE は中身を暗号化した形で、5つの部分からなります。鍵がないと中身は読めません。一方、よく見るアクセストークンや ID トークンは JWS なので、中身はだれでも読めます。JWT のペイロードには、パスワードのような秘密を入れてはいけません。

誰が何を持つか

ここからは Microsoft の場合です。Entra ID がアクセストークン(JWT)を出し、自社の Web API がそれを確かめる場面を例にします。流れを追う前に、誰が何を持っているかを押さえておきます。

Entra ID(発行する側)アプリ(運ぶ側)API(受け取る側)署名用の秘密鍵Entra ID の外に出ない公開鍵(kid ごと)jwks_uri で公開しているメタデータissuer と jwks_uri の場所アクセストークン(JWT)API に見せる中身は読まない形式は API のためのもの自分のクライアント IDaud と比べるテナント(発行者)iss と比べるEntra ID の公開鍵取ってきてキャッシュする鍵は定期的に入れ替わる24時間ごとくらいに取り直す公開鍵:Entra ID から API へ(公開されている)署名は Entra ID の秘密鍵で、検証は API が公開鍵で。アプリは運ぶだけ
図8 誰が何を持つか(Entra ID と自社の API の場合)
  • Entra ID:トークンに署名する秘密鍵を持ちます。秘密鍵は Entra ID の外に出ません。対になる公開鍵は、メタデータに書かれた jwks_uri の場所で公開しています。鍵は定期的に入れ替わります。
  • アプリ:アクセストークンを受け取って API に見せます。Microsoft のドキュメントは、アプリはアクセストークンを「中身の分からない文字列」として扱い、読んだり確かめたりしないよう書いています。中身は API のためのものだからです。
  • API:自分のクライアント ID(トークンの aud と比べる値)と、信頼するテナント(iss と比べる値)を設定に持ちます。署名の検証には、Entra ID から取ってきた公開鍵を使います。

全体の流れ(Entra ID の場合)

図8のものが、どこで作られ、どこを通って、どこで使われるのかを、一歩ずつ並べます。最初に1回行う「準備」と、トークンを使うたびに行う「実行時」に分けました。番号は2つの図を通しての番号です。

準備(最初に1回):①〜⑨

管理者API(自社の Web API)Entra ID① 署名用の鍵ペアを作る秘密鍵は Entra ID の中だけ定期的に入れ替える② 公開鍵を公開するjwks_uri の場所に置く③ API を登録するクライアント ID が決まる(aud に入る値)④ サーバで操作:設定に書くテナント ID・クライアント ID⑤ メタデータを取るGET …/v2.0/.well-known/openid-configuration⑥ メタデータissuer と jwks_uri が入っている⑦ 公開鍵を取るGET jwks_uri⑧ 公開鍵の一覧(kid ごと)秘密鍵は渡らない⑨ 公開鍵をキャッシュする24時間ごとくらいに取り直す動くのは公開鍵だけ。秘密鍵は Entra ID から出ない
図9 準備の流れ:Entra ID が鍵を作ってから、API が公開鍵を手に入れるまで
手順誰が・どこで何をするか鍵(物)はどうなるか・使う鍵
①Entra IDトークンに署名するための鍵ペアを作る。定期的に入れ替える秘密鍵は Entra ID の中だけにある
②Entra ID公開鍵を、jwks_uri の場所で公開する公開鍵はだれでも取れる
③管理者・管理センターAPI をアプリとして登録するクライアント ID が決まる(トークンの aud に入る値)
④管理者・サーバで操作API の設定に、テナント ID とクライアント ID を書く―
⑤API → Entra IDOpenID Connect のメタデータを取りに行く―
⑥Entra ID → APIメタデータが返る。issuer(発行者)と jwks_uri が入っている公開鍵の場所が分かる
⑦API → Entra IDjwks_uri に、公開鍵を取りに行く―
⑧Entra ID → API公開鍵の一覧が返る。鍵ごとに kid が付いている公開鍵が API に届く。秘密鍵は渡らない
⑨API公開鍵をキャッシュする。鍵の入れ替えに備えて、24時間ごとくらいに取り直す公開鍵を手元に置く

実行時(トークンを使うたび):⑩〜⑳

アプリ(クライアント)Entra IDAPIJWT を作る(⑩〜⑬)JWT を確かめる(⑭〜⑳)⑩ トークンを頼む認可コードフローなど(これまでの記事)⑪ ヘッダーとペイロードを作るヘッダー:alg・kidペイロード:iss・aud・exp・scp など⑫ 秘密鍵で署名するヘッダー.ペイロード に対して3つを「.」でつなぐ⑬ アクセストークン(JWT)⑭ Bearer で API を呼ぶアプリは中身を読まずに運ぶ⑮ 3つに分けるヘッダーを戻して読む⑯ kid で公開鍵を選ぶ⑨でキャッシュした鍵から⑰ 署名を検証するEntra ID の公開鍵で⑱ iss・aud・exp を確かめる(次の記事)⑲ scp・roles を見て応じるかを決める⑳ 200 + データAPI が Entra ID に毎回聞くことはない署名があるので、API は手元の公開鍵だけで本物かを確かめられる
図10 実行時の流れ:Entra ID が JWT に署名してから、API が確かめて応じるまで
手順誰が・どこで何をするか鍵(物)はどうなるか・使う鍵
⑩アプリ → Entra IDトークンを頼む(認可コードフローやクライアントクレデンシャルなど)―
⑪Entra IDヘッダー(alg・kid)と、ペイロード(iss・aud・exp・scp など)を作るkid に、署名に使う鍵の ID を書く
⑫Entra ID「ヘッダー.ペイロード」に署名し、3つを「.」でつなぐEntra ID の秘密鍵で署名
⑬Entra ID → アプリアクセストークン(JWT)を返すJWT がアプリに届く
⑭アプリ → APIAuthorization: Bearer ヘッダーに付けて API を呼ぶ。中身は読まないJWT が API に届く
⑮API「.」で3つに分け、ヘッダーを Base64URL から戻して読む―
⑯APIヘッダーの kid で、⑨でキャッシュした公開鍵から1つを選ぶEntra ID の公開鍵を選ぶ
⑰API署名を検証するEntra ID の公開鍵で検証
⑱APIiss・aud・exp などを確かめる(次の記事)―
⑲APIscp や roles を見て、応じるかを決める―
⑳API → アプリ応答(200 とデータ)を返す―

署名は⑫で1回、検証は⑰で1回です。署名は Entra ID の秘密鍵で、検証は API が持つ Entra ID の公開鍵で行います。API は、トークンを受け取るたびに Entra ID に問い合わせる必要はありません。手元の公開鍵だけで、本物か、書き換えられていないかを確かめられます。これが、JWT のような署名つきのトークンを使う大きな理由です。

共通鍵(HS256)の場合:❶〜❼

Entra ID は公開鍵の方式を使いますが、共通鍵の方式(HS256)で JWT を使う場面もあります。たとえば、自社の認証サービスが出した JWT を、自社の API だけが受け取るような場合です。図10と区別するため、番号は黒丸(❶❷…)にしています。

管理者発行する側(例:自社の認証サービス)受け取る側(例:自社の API)❶ 共通鍵を作る256ビット以上❷ 鍵を置く安全な方法で❸ 同じ鍵を置く同じ秘密が2か所に❹ HMAC で署名する共通鍵で(alg:HS256)❺ JWT を送る❻ 同じ鍵で計算し直すヘッダー.ペイロード から❼ 届いた署名と比べる一致したら本物共通鍵は、作った側と確かめる側の両方にある
図11 共通鍵(HS256)の場合の流れ
手順誰が・どこで何をするか公開鍵の方式(図9・図10)との違い
❶管理者共通鍵を作る。256ビット以上にする鍵ペアではなく、1つの鍵
❷管理者 → 発行する側発行する側に鍵を置く―
❸管理者 → 受け取る側受け取る側にも、同じ鍵を置く⑦⑧では公開鍵だけが動いた。ここでは秘密の鍵が2か所に置かれる
❹発行する側HMAC で署名する(alg は HS256)⑫と同じ位置。使うのは共通鍵
❺発行する側 → 受け取る側JWT を送る―
❻❼受け取る側同じ鍵で署名を計算し直し、届いた署名と比べる⑰の代わり。確かめる側も同じ鍵を持つので、JWT を作れてしまう

❻❼は、さきほど Python で試した手順そのものです。共通鍵の方式は、鍵が2か所にあるぶん、どちらからも漏れないように守る必要があります。同じ HMAC のしくみは、Webhook の署名の記事でも使っていました。

Microsoft(Entra ID)での手順

図9・図10の番号に沿って、Microsoft のドキュメントに載っている例を見ます。

メタデータと公開鍵を取る(図9の⑤〜⑨)

⑤で取りに行く OpenID Connect のメタデータは、次の場所にあります。この URL はどのテナントにも使える共通のものです。1つのテナントのトークンだけを受け付ける API なら、common の部分にテナント ID を入れた URL を使います。

⑤ メタデータの場所(Microsoft のドキュメントの例)

https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration

⑥で返るメタデータには、公開鍵の場所 jwks_uri が入っています。

⑥ メタデータの jwks_uri(Microsoft のドキュメントの例)

 "jwks_uri": "https://login.microsoftonline.com/{example-tenant-id}/discovery/v2.0/keys",

⑦⑧で jwks_uri を開くと、公開鍵の一覧が返ります。公開鍵の形式は JWK(JSON Web Key、RFC 7517)です。

⑧ 公開鍵の一覧(Microsoft のドキュメントの例。値は例のもの)

{
  "keys":[
    {"kty":"RSA","use":"sig","kid":"A1bC2dE3fH4iJ5kL6mN7oP8qR9sT0u","x5t":"A1bC2dE3fH4iJ5kL6mN7oP8qR9sT0u","n":"spv...","e":"AQAB","x5c":["MIID..."],"issuer":"https://login.microsoftonline.com/{tenantid}/v2.0"},
    {"kty":"RSA","use":"sig","kid":"C2dE3fH4iJ5kL6mN7oP8qR9sT0uV1w","x5t":"C2dE3fH4iJ5kL6mN7oP8qR9sT0uV1w","n":"wEM...","e":"AQAB","x5c":["MIID..."],"issuer":"https://login.microsoftonline.com/{tenantid}/v2.0"},
    {"kty":"RSA","use":"sig","kid":"E3fH4iJ5kL6mN7oP8qR9sT0uV1wX2y","x5t":"E3fH4iJ5kL6mN7oP8qR9sT0uV1wX2y","n":"rv0...","e":"AQAB","x5c":["MIID..."],"issuer":"https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0"}
  ]
}

鍵ごとに kid が付いていて、⑯ではこの kid で鍵を選びます。n と e は RSA の公開鍵の値、x5c は公開鍵を入れた証明書です。Microsoft は、Entra ID が鍵を定期的に入れ替えるので、アプリは鍵の変更に自動で対応できるように作り、24時間ごとくらいに公開鍵を確かめ直すのが妥当としています。

ヘッダーを読んで、鍵を選ぶ(図10の⑮⑯)

Entra ID のアクセストークンのヘッダーは、次のような形です。

⑮ アクセストークンのヘッダー(Microsoft のドキュメントの例)

{
  "typ": "JWT",
  "alg": "RS256",
  "x5t": "H4iJ5kL6mN7oP8qR9sT0uV1wX2yZ3a",
  "kid": "H4iJ5kL6mN7oP8qR9sT0uV1wX2yZ3a"
}

alg は RS256 で、公開鍵の方式です。kid が、署名に使った鍵の ID です。x5t は kid と同じ値で、古い v1.0 のトークンにだけ入ります。v2.0 のトークンには kid しか入らないので、鍵を選ぶときは kid を使います。

ペイロードのクレームで判断する(図10の⑱⑲)

署名を確かめたら、ペイロードのクレームを見ます。Entra ID のアクセストークンには、RFC の登録済みクレームのほかに、Microsoft 独自のクレームも入ります。

ヘッダーtypいつも JWTalg署名の方式(例:RS256)kidどの公開鍵で検証するかx5tkid と同じ(v1.0 だけ)ペイロード(主なクレーム)確かめるiss発行者(テナント)audあて先(API の ID)exp・nbf使える期間誰かoid利用者などの IDtidテナントの IDazp呼び出したアプリ何をしてよいかscp任された範囲roles与えられた許可verトークンの版署名ヘッダー.ペイロード に、Entra ID の秘密鍵で署名した値API は、署名を検証してから、ペイロードのクレームで判断する
図12 Entra ID のアクセストークンの中身(主なもの)
クレーム中身(Entra ID)
issトークンを出した Entra ID と、テナント。v2.0 のトークンでは末尾が /v2.0
audあて先。v2.0 のトークンでは、いつも API のクライアント ID。合わなければ受け付けない
exp・nbf・iat期限・開始・認証した時刻(1970年からの秒数)
oid利用者(またはサービスプリンシパル)の ID。同じテナントなら、アプリが違っても同じ値
sub主体の ID。アプリごとに違う値になる
tidテナントの ID
azpトークンを使っているアプリのクライアント ID(v2.0。v1.0 では appid)
scp任された範囲(スコープ)。人の代わりのトークンだけに入る
roles与えられた許可(アプリロール)。アプリ自身のトークンでも使う
verトークンの版(1.0 か 2.0)

Microsoft のドキュメントは、クレームは値があるときだけ入るもので、あるクレームが必ず入ること、決まった順番で並ぶことを前提にしないよう注意しています。また、name や preferred_username は変わることがあるので、表示だけに使い、判断に使わないよう書いています。

補足:v1.0 と v2.0 のトークン

Entra ID のアクセストークンには v1.0 と v2.0 があり、どちらが出るかは、受け取る API の登録(マニフェストの requestedAccessTokenVersion)で決まります。確かめるときは、トークンの ver に合わせて、v1.0 なら v1.0 の、v2.0 なら v2.0 のメタデータを使います。

中身を見るのはデバッグのときだけ

Microsoft は、開発中に中身を確かめるときに限って、jwt.ms のようなサイトで JWT をデコードできるとしています。ただし、次の点に気を付けます。

  • アプリは読まない:アプリ(クライアント)は、アクセストークンを中身の分からない文字列として扱い、確かめようとしてはいけません。期限などは、トークンと一緒に返る expires_in などで分かります。
  • JWT とは限らない:Microsoft のサービス向けのトークンは、JWT としてデコードできないこともあります。Microsoft Graph のトークンは独自の形式なので、この記事の手順では確かめられません。自分の API あてでないトークンを確かめようとしないのが原則です。
  • 確かめるのはライブラリで:ASP.NET Core なら Microsoft.Identity.Web が、検証を代わりに行います。署名の検証を自分で書くより、ライブラリを使うのが基本です。

うまくいかないとき

起きていることよくある原因確かめること
jwt.ms でデコードできないMicrosoft のサービス向けのトークンで、JWT の形ではないそのトークンの aud が自分の API か。他人の API のトークンなら、読む必要はない
署名の検証で、鍵が見つからないEntra ID が鍵を入れ替え、キャッシュに新しい kid がない公開鍵を取り直す作りになっているか(ライブラリの設定)
発行者(iss)が合わないv1.0 のトークンを v2.0 のメタデータで確かめている(またはその逆)トークンの ver と、使っているメタデータの URL
あて先(aud)が合わないGraph あてなど、ほかの API あてのトークンを送っているアプリが頼んでいる scope が、自分の API のものか
期限の判定がおかしい秒数をミリ秒として扱っている、またはサーバの時刻がずれているexp は1970年からの秒数。サーバの時刻同期

企業での例

場面JWT の使い方押さえどころ
社内の Web API を、Entra ID のトークンで守るAPI が Entra ID の公開鍵で署名を確かめるMicrosoft.Identity.Web などのライブラリに任せ、aud と iss を正しく設定する
トークンの中身を見て、問い合わせを調べるjwt.ms で、自社 API あてのトークンをデコードするデバッグのときだけにする。トークンは資格情報なので、チャットやチケットに貼らない
自社の2つのシステムの間で、JWT を受け渡すHS256(共通鍵)か RS256(公開鍵)受け取る側が増えそうなら、最初から公開鍵の方式にしておく
JWT に業務の情報を入れたいペイロードにクレームを足す中身はだれでも読める。秘密や個人情報は入れない

まとめ

項目内容
形ヘッダー.ペイロード.署名 の3つを「.」でつないだ文字列(JWS の場合)
Base64URL文字の置き換えで、暗号ではない。中身はだれでも読める
クレームiss・sub・aud・exp・nbf・iat・jti などの情報。RFC ではどれも必須ではなく、使う側が決める
署名ヘッダーとペイロードから計算した値。1文字でも変わると合わなくなる
署名の方式共通鍵(HS256)か公開鍵(RS256・ES256 など)。Entra ID は公開鍵の方式
Entra ID の場合Entra ID が秘密鍵で署名し、API は jwks_uri から取った公開鍵で検証する。アプリは読まずに運ぶ
注意ペイロードに秘密を入れない。署名なし(none)を受け付けない。確かめるのはライブラリで

参考にした資料

この記事は、次の標準と公式ドキュメントで内容を確かめて書きました。仕様や対応状況は変わることがあるので、最新の情報はリンク先で確かめてください。

  • RFC 7519: JSON Web Token (JWT)(IETF):JWT の定義、JWS か JWE で表すこと、例(3.1)、登録済みクレームとどれも必須ではないこと(4.1)、自由に決められるクレーム名(4.2・4.3)、NumericDate、typ の推奨値
  • RFC 7515: JSON Web Signature (JWS)(IETF):Base64url の定義、署名の対象(JWS Signing Input)、3つの部分のつなぎ方、alg が必須であること、kid の意味、HS256 の例と鍵(付録 A.1)
  • RFC 7518: JSON Web Algorithms (JWA)(IETF):HS256・RS256・ES256・PS256・none の意味、HMAC の鍵の長さ、none を既定で受け付けないこと
  • RFC 7516: JSON Web Encryption (JWE)(IETF):JWE が暗号化した内容を表すこと、5つの部分
  • Access tokens in the Microsoft identity platform(Microsoft Learn):アプリはアクセストークンを読まないこと、jwt.ms はデバッグ用、JWT でないトークンがあること、Graph のトークンは独自形式、API が aud を確かめること、RS256 とヘッダーの例、kid と x5t、メタデータと jwks_uri、公開鍵の一覧の例、鍵の入れ替えと24時間ごとの確認、v1.0 と v2.0 のメタデータ、Microsoft.Identity.Web
  • Access token claims reference(Microsoft Learn):ヘッダーとペイロードのクレーム(aud・iss・iat・nbf・exp・oid・sub・tid・azp・scp・roles・ver など)、クレームが必ず入るとは限らないこと、name や preferred_username を判断に使わないこと

あとがき

JWT は見た目が暗号のような文字列なので、なんとなく「中身は守られている」と思っていました。実際に RFC の例を Python で戻してみると、鍵がなくても数行でそのまま読めてしまい、守っているのは中身ではなく「本物かどうか」なのだと実感しました。次の記事では、受け取る側が何をどの順番で確かめるのかを見ていきます。

コメント