iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

JWT認証の仕組みについて調べてみた

■はじめに

株式会社iimonでエンジニアをしている「白水(しらみず)」です。

みなさま、明けましておめでとうございます。

本年もよろしくお願いいたします。

新規サービスの開発にあたり認証周りをどうするか考えていたのですが、その中でJWT認証について調べる機会があったので、まとめてみました。

図も交えて解説しているので、ぜひご覧ください。

■認証とは?

認証とは、「本人確認してこの人は本人だね、使ってOKと判断する仕組み」のことです。

要するに、ログインなどは「認証」という分類になります。

似たような言葉に「認可」がありますが、「認可」は許可をするかどうかという意味です。

例えば、Aさんは「AWSのSecretManagerの値を見れる」けど、Bさんは見れないというように、個人や個人を集めたグループに対して、何を許可して、何を許可しないかを与えることを「認可」といいます。

■JWT認証とは

JWT認証の特徴としては、以下があります。

  1. JWTは、JSON Web Token の略である
  2. トークン自体にユーザーを識別する情報が含まれている
  3. サーバーはDBを参照せずにトークンが正しいか検証できる
  4. 署名(Signature)により改ざんを検知できる

特に、私は③と④がピンと来ないなーと感じていました。

■セッション認証方式

◆処理フロー

  1. クライアントがログインする前にページにアクセスすると「CSRFToken」をサーバーから受け取って、ブラウザ内のCookieに保存してページを表示します。
  2. クライアントがログインするときに、ユーザー名とパスワードをサーバーに送りますが、その際にCookieに保存されている「CSRFToken」もサーバーに送信します。(ブラウザが勝手に送ってくれます)
    サーバー側では、ユーザー名とパスワードで認証をして、認証がOKであれば「セッションID」を作成して、DB または RedisなどのNoSQLDBに保存します。
    このときに、サーバーが発行した「セッションID」と「ユーザー情報」が紐づいた形で保存されます。
    サーバからクライアントへのレスポンスでステータスコードで「200番」を返しますが、レスポンスヘッダーの中に「Set-Cookie」というキーと「サーバーが発行したセッションID」をkey: valueの形でクライアントへ返します。
    クライアントは、ブラウザ内のCookieに「サーバーが発行したセッションID」を保存します。

  3. クライアントから他のページにアクセスするときに、ブラウザ内のCookieに保存している「セッションID」をサーバーに送信します。

  4. サーバーは、クライアントから送られてきた「セッションID」と同じ値がDB または RedisなどのNoSQLDBに保存されているか確認して、保存されていれば認証済みとして、何かしらのデータをクライアントに返します。

  5. ログアウトする場合は、DB または RedisなどのNoSQLDBに保存している「セッションID」を削除することで可能にします。

このあたりは、ステートレスとステートフルという知識も必要なので、以下記事なども確認ください。

www.cloud-for-all.com

セッション認証方式の課題点

セッション認証には、以下のような課題があると思っています。

  • 各リクエストでDB/Redis へのアクセスが必要
  • サーバー側でセッション状態を管理する必要がある
  • サーバーAとサーバーBがあって、サーバーAにはユーザー①の「セッションID」があるけど、サーバーBにはユーザー①の「セッションID」がないので、ユーザー①がサーバーBにリクエストを送ると弾かれる。
    なので、サーバーAとサーバーBで共通の「セッションID管理」が必要になる

しかし、セッション認証は、現在でも広く使われていると思います。
特に、サーバーサイドでHTMLを組み立てるようなサイトの場合は、セッション認証が使われている気がします。
逆に、SPAサイト系はJWT認証が好まれているのかなと思っています。

■JWTの構造

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxMjM0NX0.XXXXXXXXXXXXXXXX
|______ Header ____|.|______ Payload ______|.|__ Signature __|

JWTを実現するAccessTokenは、3つの部分に分けられます。

  • Header(eyJhbGciOiJIUzI1NiJ9)

    • ここには、「これはJWT」だよという情報が入っています。
      Base64エンコードされているので、デコードすれば中身を確認できます。
  • Payload(eyJ1c2VyX2lkIjoxMjM0NX0)

    • ユーザーIDなどの中身の情報がBase64エンコードされて入っています。
      デコードすれば中身を確認できます。
  • Signature(XXXXXXXXXXXXXXXX)

    • 偽造防止の印鑑のような役割を担う部分で、JWTを理解するのにとても大事な部分です。
      これは、Base64エンコードではないので、デコードしても中身はわかりません。
// Base64 エンコード
btoa('{"user_id": 123}')'eyJ1c2VyX2lkIjogMTIzfQ=='

// Base64 デコード
atob('eyJ1c2VyX2lkIjogMTIzfQ==')'{"user_id": 123}'

Base64 エンコードは、JSONBase64文字列 に変換することです。

Base64は誰でも簡単にデコードして中身を見れるので、Payloadに機密情報(パスワード等)を入れてはいけません。

Header(ヘッダー)
{
    "alg": "HS256",    ← 署名アルゴリズム(HMAC-SHA256)
    "typ": "JWT"       ← これはJWTですよ 
}

Payload(ペイロード)
{
    "user_id": 12345,           ← 誰か
    "exp": 1705312800           ← AccessTokenの有効期限(Unix時間)
}

Signature(署名)の作り方
HMAC-SHA256(Header + "." + Payload + SECRET_KEY)
※ SECRET_KEYを知っている人だけが正しい署名を作れる
※ 内容を1文字でも変えると、署名が合わなくなる

JWTのAccessTokenを作る3つの構成は、上のようになります。

Headerには「署名アルゴリズム」と「これはJWTですよ」というtypeがオブジェクトの形式で入っています。

Payloadには、ユーザーを判別するためのidなどの情報と、AccessTokenの有効期限がオブジェクトの形式で入っています。

Signatureは署名をするための印鑑のような役割で、「Header + "." + Payload + SECRET_KEY」を組み合わせた値をHMAC-SHA256(Hash-based Message Authentication Code)という暗号化の関数を通して暗号化することで作成します。

SECRET_KEYは、外部に絶対に知られてはいけない情報なので、AWS SecretManagerなどで本番では管理して、それを使うようにします。

■HMAC(Hash-based Message Authentication Code)とは

「メッセージ認証コード」を作るための仕組みです。

メッセージ(データ)+ 秘密の鍵を組み合わせることで、固定長の文字列を作成します。

JWTでは、以下のような形になります。

  • メッセージ(データ):Header + "." + Payload
  • 秘密の鍵:SECRET_KEY(AWS SecretManagerなどに持つ)
  • 出力: 署名(Signature)が発行される

Signatureの作成の流れは、以下のようになります。

SECRET_KEY = "xK9#mP2$vL5nQ8..."(サーバーだけが知っている情報)

【Aさん(user_id: 123)の署名】
入力 = "eyJhbGci..." + "." + "eyJ1c2VyX2lkIjoxMjN9"
鍵   = "xK9#mP2$vL5nQ8..."
           ↓
       HMAC-SHA256
        ↓
    出力 = "ABC123XYZ"   ← Aさん用の署名

【Bさん(user_id: 456)の署名】
入力 = "eyJhbGci..." + "." + "eyJ1c2VyX2lkIjo0NTZ9"
鍵   = "xK9#mP2$vL5nQ8..."   ← 同じSECRET_KEY
                        ↓
                HMAC-SHA256
                        ↓
出力 = "DEF456UVW"   ← Bさん用の署名(Aさんとは違う値)

特徴としては、以下があります。

  • 同じ入力 → 必ず同じ出力が得られます。
  • SECRET_KEYは全ユーザー共通です。
  • Payloadが違うと、署名(Signature)は異なります。
  • 出力から入力を逆算できない特徴があります。(SECRET_KEYが外部に漏洩したら、ワンちゃんわかるかも)
  • 入力が1文字でも変わると、出力が全く違う値になります。
  • 秘密の鍵を知らないと、正しい出力を作れないです。
  • SECRET_KEYは全ユーザー共通(1つだけ)ですが、Payload(例:user_id など)がユーザーによって異なる違うため、署名も違う値になります。
  • DBに「セッションID」のように保存しなくても、認証の仕組みが成立します。

■アクセストークンとリフレッシュトーク

JWTには、アクセストークンとリフレッシュトークンという2つのトークンがあります。

なぜ2つあるかと言うと、アクセストークンに30日間くらいの長い有効期限を設けていると仮定して、アクセストークンを悪い人に盗まれたとします。

JWTでは、DBにアクセストークンを持っていないので、30日間は悪い人に悪用されることになります。

そこでアクセストークンには「15分〜1時間」くらいの短い有効期限を設けて、リフレッシュトークンに対して、「30日くらいの長い有効期限」を設けて、リフレッシュトークンをDBに保存しておきます。(リフレッシュトークンは、DBに保存しないで運用するサービスもあるので、あくまで一例とします)

そうすると、悪い人にアクセストークンを盗まれたら、DBのリフレッシュトークンを削除することでアクセストークンには「15分〜1時間」くらいの短い有効期限を設けているので、1時間くらいで無効になって、悪用を最低限の抑えることができます。

まとめると、以下のような役割に分けられます。

  • アクセストーク
    • 用途:API呼び出し
    • 有効期限:短い(1時間と仮定する)
    • 保存:DB保存しない
    • 盗まれたら:1時間で無効
  • リフレッシュトーク
    • 用途:アクセストークン再発行
    • 有効期限:長い(7日〜30日)
    • 保存:DBに保存する(保存しなくてもJWT認証は実現できる)
    • 盗まれたら:DBから消せばOK

アクセストークンとリフレッシュトークンのライフサイクルは以下のようになります。

■JWTを使った認証フローを確認してみる

ログイン(トークン発行)

ログインの流れは、以下になります。

  1. ログインフォームにメールアドレスとパスワードを入れて、サーバーに送る
  2. サーバー側でDBからメールアドレスが同じユーザーを探す
  3. ユーザーが存在すれば、ユーザーが送ってきたパスワードをhash化して、メールアドレスが同じユーザーのハッシュ化されたパスワードが同じかどうか検証する。(hash化したパスワード同士で検証する)
  4. パスワードが一致したら、AWS SecretManagerからJWT用のSECRET_KEYを取得する
  5. Header + "." + Payload + HMAC(Header + "." + Payload + SECRET_KEY) + “有効期限(1時間)”でアクセストークンを作成する
  6. refreshToken(7日)を作成して、DBに保存する。
  7. アクセストークンをユーザーに返す
  8. ブラウザのlocalStorageかCookieに保存する。
┌──────────┬─────────────────┬──────────────┬─────────────┬─────────────┐
│ id       │ email           │ password_hash│refresh_token│refresh_exp  │
├──────────┼─────────────────┼──────────────┼─────────────┼─────────────┤
│ uuid-123 │ tanaka@test.com │ $2b$12$...   │ xyz789...   │ 2025-01-21  │
│ uuid-456 │ suzuki@test.com │ $2b$12$...   │ NULL        │ NULL        │
└──────────┴─────────────────┴──────────────┴─────────────┴─────────────┘ 

ログインした時点でのusersテーブルの状況としては、上のようになります。

ログインした人だけトークンが入っています。

access_token は DB に保存しないで、refresh_token は DB に保存している状況になります。

処理フローの中でも、⑤のHMAC(Header + "." + Payload + SECRET_KEY)部分で、SignatureはHeader + "." + Payload + SECRET_KEYで作られていることを理解しておいたほうが、後々のアクセストークンが盗まれた場合の話で理解しやすいと思います。

API呼び出し(アクセストークン検証)

API呼び出しの流れは、以下になります。

  1. フロントで保存していたアクセストークンを添付してAPIを叩く
  2. AWS SecretManagerからJWT用のSECRET_KEYを取得する。
  3. フロントから送られてきたアクセストークンを「.」で区切って、HeaderPayloadSignatureに分解する。
  4. フロントから送られてきたアクセストークンのHeader + "." + PayloadSECRET_KEYHMAC()に通して、アクセストークンを再計算する。
    サーバー側で再計算したアクセストークンと、フロントから送られてきたアクセストークンが同じであれば、改ざんされていないので次に進む。
  5. アクセストークンの有効期限を確認する。
  6. 顧客データをDBから検索して、フロントに返す。

アクセストークンを使ったAPI呼び出しでは、以下の特徴があります。

  • 認証に関して、DBへのアクセス不要(セッション認証はDBアクセスする)
  • 署名検証のみで認証完了
  • 改ざん検知が可能
  • JWTは「無効化できない」というデメリットもある

特に④のアクセストークンを再計算する部分は、「フロントから送られてきたアクセストークンのHeader + "." + PayloadSECRET_KEYHMAC()に通して、アクセストークンを再計算する。」という部分が大事で、これにより改ざん検知できます。

改ざん検知は、後半で解説します。

トークン更新(リフレッシュ)

トークン更新の流れは、以下になります。

  1. クライアントから商品一覧ページのAPIを叩く(アクセストークンをサーバーに送る)
  2. サーバー側でアクセストークンの有効期限を確認すると、切れているので「401 Unauthorized」 エラーを返す
  3. クライアントからリフレッシュトークンのAPIを叩く(ローカルストレージかCookieに保存しているリフレッシュトークンも一緒にサーバーに送る)
  4. サーバーでDBからリフレッシュトークンが同じレコードを探す
  5. リフレッシュトークンの有効期限を確認する
  6. 新しいアクセストークンを発行して、有効期限を伸ばす
  7. クライアントにアクセストークンを返す
  8. クライアントはアクセストークンをローカルストレージかCookieに保存して、もう一度商品一覧ページのAPIを叩く

このフローの中の特徴として

  • リフレッシュトークンはDBで管理するため、ログアウト時に無効化できる
  • リフレッシュトークンのAPIを叩いたときだけ、DBアクセスが発生する(リフレッシュトークンをDBに保存しないパターンでは、DBアクセスは発生しないので例外あり)
  • localStorage保存は避ける(XSS対策)方が良いらしい。Cookie保存が一般的?には推奨らしいです。

ログアウト

ログアウトの流れは、以下になります。

  1. クライアントはログアウトのAPIを叩く
  2. アクセストークンの中に含まれるユーザーの識別情報から、DBにアクセスして、リフレッシュトークンを削除する
  3. クライアントにログアウトしたメッセージとステータスコードを返す

このフローの中の特徴として

  • リフレッシュトークンは、DBから消したので即無効になります。
    そのため、削除以降は新しいアクセストークンは発行できません。

  • アクセストークンは有効期限まで技術的には使えます。なので、期限を短く設定します。
    ログアウト後に攻撃者がアクセストークンを盗んでも、1時間後には完全に無効化されるので、実質的な被害は限定的にすることができます。

しかし、完全にはクライアントとサーバーのセッション(ある期間・一連の活動や集まり)を切れないという課題もあるため、JWTは完璧というわけではなさそうです。

■悪者がJWTを盗んで偽造した場合に検知される仕組み

JWTアクセストークンの偽造検知の仕組み

JWTアクセストークンの偽造検知の仕組みは以下になります。

  1. HeaderをBase64エンコードした値、PayloadをBase64エンコードした値、HeaderとPayloadとSECRET_KEYをくっつけて、HMACを通して作成されたSignatureを作成する。
    3つの値を「.」で繋いだアクセストークンをサーバーで発行して、クライアントに返す

  2. クライアントは、localStorageやCookieに保存する

  3. 悪者がJWTアクセストークンを何らかの方法で盗む(ex. XSSなど)
  4. 悪者がJWTアクセストークンのHeaderやPayloadを、JavaScriptatob()関数でデコードして、中身を確認して、Payloadを書き換えて、btoa()関数エンコードする。(Signatureはatob()関数でデコードしても見れない)
    改ざんしたアクセストークンで、APIを叩く。
  5. サーバー側で、クライアントから送られてきたアクセストークンを「.」で分割して「Header」「Payload」「Signature」に分ける。
    クライアントから送られてきた「Header」「Payload(改ざんされている)」「AWSから取得したSECRET_KEY」を使って、「Signature」を再計算する。
    再計算した「Signature(Payloadが改ざんされている)」とクライアントから送られてきた「Signature(悪者はSECRET_KEYを知らないので改ざんできない = Payloadは元の{"user_id": 123}のまま)」で比較する
  6. 比較した結果、「Signature」が異なるので、改ざんされていると検知できる。

悪者ができることことは、Payloadを書き換えることしかできないです。

悪者はSECRET_KEYを知らないため、改ざんしたSignatureを作成できないことになります。

もし、SECRET_KEYが漏洩すると、悪者はPayloadを改ざんできますし、Signatureも自分で計算して改ざんできることになります。

その結果、完璧な偽造JWTトークンを作成できて、誰にでもなりすませる事ができるようにになります。

このようなことにならないために、以下の点を注意しないといけません

  • コードに直接SECRET_KEYを書かない
  • SECRET_KEYをGitHubにpushしない
  • ログに出力しない
  • フロントエンドにSECRET_KEY渡さない

SECRET_KEYを守るために、以下のような対策をする必要があります。

  • AWS Secrets Manager で厳重に管理する
  • AWS IAMで「誰がアクセスできるか」という認可を制限する
  • 監査ログで「いつ誰がアクセスしたか」を記録する

■まとめ

JWT認証の仕組みや改ざんに対してどのように防ぐのかがわかって、すごく勉強になりました。 理論はわかったので、今後は実践ベースに落とし込んだり、その他の認証方式についても知見を広げていければと思います。

ここまで読んでくださってありがとうございました。

弊社ではエンジニアを募集しています!少しでもご興味がありましたら、ぜひカジュアル面談でお話しましょう!

iimon採用サイト / Wantedly

■参考記事

www.jwt.io

ja.wikipedia.org

初心者向けJWT講座:JSON Web Tokenを使った認証の仕組み