WebログインとLIFF/SDKにおけるLINEログインのIDトークンの署名方式の違い
リテールアプリ共創部のるおんです。
LINEログインで受け取った IDトークン の署名を検証しようとしたとき、気になった点がいくつかありました。この記事は、そのとき疑問に思って混乱したところと、調べて理解した流れをまとめたものです。
最初に LINE の OpenID Configuration を見たら、署名アルゴリズムは ES256(公開鍵方式)と書いてありました。なので「JWKS から公開鍵を取ってきて ES256 で検証すればいい」と思って実装したのですが、実際に Webのログインで受け取った IDトークンは HS256(共通鍵方式)で署名されていて、検証が一向に通りませんでした 。
実際のOpenID Configurationは以下のようになっています。
{
"issuer": "https://access.line.me",
"authorization_endpoint": "https://access.line.me/oauth2/v2.1/authorize",
"token_endpoint": "https://api.line.me/oauth2/v2.1/token",
"revocation_endpoint": "https://api.line.me/oauth2/v2.1/revoke",
"userinfo_endpoint": "https://api.line.me/oauth2/v2.1/userinfo",
"scopes_supported": ["openid", "profile", "email"],
"jwks_uri": "https://api.line.me/oauth2/v2.1/certs",
"response_types_supported": ["code"],
"subject_types_supported": ["pairwise"],
+ "id_token_signing_alg_values_supported": ["ES256"], // 公開鍵方式
"code_challenge_methods_supported": ["S256"]
}
「configには ES256 って書いてあるのに、なんで実物は HS256 なんだ」としばらく混乱しました。結論から言うと、これは LINEログインの実装方法(Webログイン か LIFF/SDK か)によって署名方式が変わる という仕様が理由でした。
先に結論
- LINEログインの IDトークンの署名方式は、実装方法によって2種類 ある
- Webのログイン(ブラウザでの認可コードフロー)→
HS256(共通鍵 / チャネルシークレットで署名・検証) - LIFF / ネイティブSDK 経由のログイン →
ES256(公開鍵 / JWKS の公開鍵で検証)
- Webのログイン(ブラウザでの認可コードフロー)→
- ところが OpenID Configuration には
ES256しか書かれていない 。なので「Config を信じてES256で検証する」実装にすると、Webログインのトークン(HS256)が検証できずにハマる - 見分け方はシンプルで、IDトークンのヘッダーを見ればいい 。
HS256ならkidが無く、ES256ならkidがあって JWKS の鍵と対応する
| Webログイン | LIFF / SDK | |
|---|---|---|
alg |
HS256 |
ES256 |
| 鍵の種類 | 共通鍵(チャネルシークレット) | 公開鍵 / 秘密鍵 |
| 検証に使うもの | チャネルシークレット | JWKS(jwks_uri の公開鍵) |
ヘッダーの kid |
無し | 有り(JWKS の鍵IDと一致) |
背景:IDトークンの署名を検証したかった
そもそも、なぜ署名を検証するのか。IDトークンは「誰がログインしたか」をアプリに伝えるものなので、「それが確かに LINE が発行した、改ざんされていないトークンか」を検証してから信用する 必要があります。検証せずに中身(sub など)を信じてしまうと、偽造したトークンを掴まされるリスクがあります。
JWT の署名検証では、まず どのアルゴリズム(alg)と、どの鍵で署名されているか を知る必要があります。それは推測するものではなく、IdP(ここでは LINE)が公開している OpenID Configuration を見れば分かる と思っていました。
curl https://access.line.me/.well-known/openid-configuration
返ってきた JSON のうち、署名検証に関係するのはこのあたりです。
{
"issuer": "https://access.line.me",
"jwks_uri": "https://api.line.me/oauth2/v2.1/certs",
"id_token_signing_alg_values_supported": ["ES256"],
"subject_types_supported": ["pairwise"]
}
id_token_signing_alg_values_supported が ["ES256"] 。つまり「IDトークンは ES256で署名するよ」と宣言されています。そして jwks_uri で 検証用の公開鍵 が公開されています。
なので方針は、jwks_uri から公開鍵を取得し、ES256 でIDトークンの署名を検証する ということだと考えました。
ところが、jose ライブラリで ES256 検証を実装して、Webのログインフローで受け取った実物のIDトークンに対して走らせると、検証が通りません 。
JWSSignatureVerificationFailed: signature verification failed
(または ERR_JOSE_ALG_NOT_ALLOWED)
「鍵の取り違えかな?」と思って、そもそも実物のトークンが何で署名されているのか を確かめることにしました。JWT のヘッダー(1つ目のパート)をデコードします。
echo '<id_token>' | cut -d '.' -f1 | python3 -c "import sys,base64,json;p=sys.stdin.read().strip();print(json.loads(base64.urlsafe_b64decode(p+'='*(-len(p)%4))))"
出てきたのはこれでした。
{
"alg": "HS256",
"typ": "JWT"
}
ES256 ではなく HS256 。しかも、ES256 なら署名検証に使う鍵を指す kid(Key ID)が入っているはずなのに、kid がありません 。
HS256 は HMAC-SHA256 、つまり 共通鍵(対称鍵)方式 です。LINEログインで両者が共有している秘密といえば、チャネルシークレット です。試しに、検証する鍵を チャネルシークレット に変えて HS256 で検証し直すと、今度はあっさり通りました。
つまり、OpenID Configuration は ES256 と言っているのに、Webログインの実物は HS256(チャネルシークレットによる共通鍵署名)だった 。ここで完全に「どっちが本当なんだ」と混乱しました。
一次情報を確認する:公式ドキュメント
自分の勘違いやコードのミスを疑い切ったところで、一次情報 を当たることにしました。
LINE の公式ドキュメント「IDトークンを検証する」に、まさにこのことがはっきり書かれていました。

ネイティブアプリやLINE SDK、LIFFアプリに対してはES256(ECDSA using P-256 and SHA-256)が、ウェブログインに対してはHS256(HMAC using SHA-256)が返されます。
検証に使う鍵も、署名方式ごとに分けて書かれています。
ES256の場合 … JWKドキュメント(jwks_uri)の中の、ヘッダーのkidに対応する 公開鍵HS256の場合 … チャネルシークレット
つまり、同じLINEログインでも、Webでログインしたか、SDK/LIFF でログインしたかで署名方式が変わるということです 。そして OpenID Configuration が公開しているのは ES256 のほうだけ、というのも合点がいきました。
裏取り:LIFF版のトークンも確かめる
ドキュメント上は「LIFF なら ES256」とのことなので、本当にそうなるのか を実物で確かめます。LIFF SDK 経由で取得したIDトークンのヘッダーをデコードしてみます。
普段開発しているLINEミニアプリのネットワークタブからid_tokenを拾ってきました。
{
"alg": "ES256",
"typ": "JWT",
"kid": "95e9119f..."
}
今度は ES256 、そして kid 付き です。この kid が、jwks_uri(https://api.line.me/oauth2/v2.1/certs)が返す公開鍵セットの中の、どの鍵で検証すべきかを指しています。実際に JWKS を取得して kid を突き合わせると、ちゃんと 同じ kid の公開鍵が存在 しました。
ちなみに余談ですが、Web版とLIFF版で 同じ自分のアカウントなのに sub(ユーザー識別子)の値が違う のも確認できました。これは OpenID Configuration の subject_types_supported が pairwise だったこととも整合します。pairwise は「宛先ごとに別々の sub を発行する」方式で、LINE の場合その単位は プロバイダー です。同じプロバイダー配下のチャネルなら sub は同じで、プロバイダーが違えば別の sub になります。こうして、サービスをまたいだ名寄せを防いでいます。
普段LINEミニアプリ開発をする中で、プロバイダーが違えばUIDが異なるということをよく知っていたので、これは合点がいきました。
{
"issuer": "https://access.line.me",
"authorization_endpoint": "https://access.line.me/oauth2/v2.1/authorize",
"token_endpoint": "https://api.line.me/oauth2/v2.1/token",
"revocation_endpoint": "https://api.line.me/oauth2/v2.1/revoke",
"userinfo_endpoint": "https://api.line.me/oauth2/v2.1/userinfo",
"scopes_supported": ["openid", "profile", "email"],
"jwks_uri": "https://api.line.me/oauth2/v2.1/certs",
"response_types_supported": ["code"],
+ "subject_types_supported": ["pairwise"], // プロバイダーごとに別々の sub を発行する
"id_token_signing_alg_values_supported": ["ES256"], // 公開鍵方式
"code_challenge_methods_supported": ["S256"]
}
なぜ署名方式が分かれているのか
ここまで来ると「なぜそんなややこしい仕様に?」が気になります。共通鍵(HS256)と公開鍵(ES256)の性質を踏まえると、理由が見えてきます。
HS256(共通鍵) は、署名にも検証にも 同じ秘密の鍵 を使います。なので 検証する側が秘密鍵(チャネルシークレット)を安全に持てる ことが前提です。Webアプリは サーバーサイドでチャネルシークレットを秘密に保持できる ので、共通鍵方式が成立します。鍵を共有している分、JWKSを取りに行く必要もなく、速くてシンプルです。ES256(公開鍵) は、秘密鍵で署名し、誰でも入手できる公開鍵で検証 します。検証側は秘密を持たなくていいのが利点です。LIFF やネイティブSDK は クライアント(端末)側で動く=チャネルシークレットを安全に隠せない ため、共通鍵を配るわけにはいきません。だから「秘密鍵は LINE 側だけが持ち、検証は公開鍵(JWKS)で行う」公開鍵方式が選ばれている、と理解できます。
つまり、「検証する側がチャネルシークレットを安全に保持できるか」 という実装環境の違いが、署名方式の違いに表れているということで自分は理解しました。
そしてこの「秘密を安全に持てるか」という違いは、OAuth/OIDC でいう コンフィデンシャルクライアント(サーバーを持つWeb)と パブリッククライアント(端末で動くLIFF/SDK) の違いそのものです。秘密を持てる側はバックチャネルでトークンを受け取れるので共通鍵で済み、持てない側はトークンが手元に来る分、公開鍵で検証するしかないです、認可コードフローとインプリシットフローの使い分けと、根っこは同じ問いから来ています。
おわりに
「Config に ES256 と書いてあるのに、Webログインの実物は HS256 だった」という混乱から始まって、検証 → 公式ドキュメント と裏を取っていった結果、「Webログインは共通鍵(HS256/チャネルシークレット)、LIFF/SDK は公開鍵(ES256/JWKS)」 という結論にたどり着きました。
認証まわりはライブラリ任せにしがちですが、「実物をデコードして確かめる」 癖をつけておくと、今回のような Config と実装の食い違いに出くわしても落ち着いて切り分けられます。
以上、どなたかの参考になれば幸いです。
参考







