HMAC署名トークンで招待リンクを作成する

HMAC署名トークンで招待リンクを作成する

1. 一言で先に言うと

HMACは、招待リンクをサーバーが発行し、サーバーが検証するときに使う共有秘密鍵です。フロントエンドは署名を計算せず、キーもリンクごとに異なるわけではありません。環境ごとにキーは1つで、リンクごとに異なるのはプールIDと有効期限が入ったペイロードです。

この記事では、人材プールの招待リンクに有効期間を付けるにあたり、HMAC-SHA256署名トークンを選んだ背景、最初に誤解していた点、実際にコードを組み込み、デプロイ設定を分けた過程、そして同じ失敗を繰り返さないために残しておく実務メモをまとめます。理論の整理よりも、「なぜそのときそうしたのか、何が間違っていたのか」を中心に書いています。

2. なぜこの技術を使うことになったのか

2.1 製品要件

班長が人材プールに人を呼ぶときは、共有リンクを使います。既存のリンクは、クエリにプールIDだけを入れる形式でした。リンクを受け取った作業員がアプリに入ると、そのプールへの招待が作成される流れです。問題は、有効期間がなかったことです。一度広まったリンクはいつまでも再び開けてしまい、URLさえ分かればプールIDを変更して別のプールを指定することも、理論上は可能でした。

製品側からは、「招待シートを開くたびに新しいリンクを作るが、そのリンクは複数の作業員が一緒に使えるようにしてほしい」という要件がありました。有効期限は必要であり、有効期限をクライアント側で隠してはならず、サーバーが検証時点で拒否する必要がありました。一覧画面で期限切れの招待を隠すだけでは不十分でした。

2.2 まず代替案を並べて選んだ理由

最初は3つの案を並べて検討しました。

  • 既存の共有リンクはそのままにして、案内文だけを「数日以内にアクセスしてください」とする

  • プールIDと有効期限をBase64だけで折りたたんでURLに入れる。署名はなし

  • プールIDと有効期限をペイロードにし、サーバーの秘密鍵でHMAC-SHA256タグを付けて、1つのトークンにする

1つ目は実装が最も簡単です。ただし要件は「有効期限を検証する」ことなので、案内だけでは対応できません。2つ目はURLをデコードすれば、有効期限を延ばしたりプールIDを変更したりできます。知っている人だけが書き換えられる構造です。3つ目は、キーを持つサーバーだけが同じタグを作れるため、URLを書き換えると検証に失敗します。リンクを複数人で共有するモデルにも適していました。セッションCookieや一回限りのログインコードは、共有リンクには適していませんでした。

そこでHMAC署名トークンを選びました。JWTを使わなかった理由は、このトークンに必要なのがログイン主体ではなく、「このプールへの、この時刻まで有効な招待」という短い主張だけだったからです。クレームが2つだけなら、直接折りたたんだペイロードのほうが読みやすくなります。

3. 最初にどう考え、何を間違えていたのか

技術を名前だけで知っているときと、デプロイまで実際に組み込んでみたときの隔たりは大きいものでした。以下は、当時私が持っていた誤解です。

3.1 共通のHmacUtilを新しく作る必要がある

最初は、標準Base64を使う共通ユーティリティをもう1つ追加すればよいと考えていました。コードを調べてみると、ドメインサービス内にはすでにHMAC-SHA256があり、URLに入れるためにURL-safe Base64(パディングなし)を使っていました。標準Base64の+、/、=はクエリ文字列を壊す可能性があります。新しいユーティリティを作る前に、すでに使われているエンコーディングに合わせることが先でした。

3.2 Secretにキー名だけ入れればアプリが自動的に読み込む

Kubernetes Secretオブジェクトにhmac-secretのような名前を作っておけば、Springが自動的に読み込むものだと思っていました。実際には、アプリケーションのymlが環境変数名を読み取り、その名前がPodのenvとして渡されていなければなりません。Secretのキーがymlのプレースホルダーと1文字でも異なると、アプリはデフォルト値を使うか、空の値を参照します。「Secretを作成した」ことと「Podがその名前で変数を受け取った」ことは別の話です。

3.3 フロントエンドが署名を計算する

リンクを作成する画面がフロントエンドにあるため、フロントエンドがキーでHMACを計算してAPIに送るのだと思っていました。そうすると秘密鍵がバンドルに含まれてしまいます。実際の流れは逆です。班長が招待リンクをリクエストすると、サーバーがトークン文字列を返し、フロントエンドはクエリにその文字列だけを付けます。作業員がリンクからアクセスすると、同じ文字列を登録APIにそのまま送ります。検証もサーバーが行います。

3.4 リンクごとにHMACキーが異なる

招待ごとにキーが変わると、運用上キーを保管する方法がありません。キーは環境(開発・ステージ・本番)ごとに1つで、リンクごとに異なるのはペイロードです。同じキーで、異なるプールIDと異なる有効期限に署名します。

3.5 ステージにもConfigMapが必須である

ymlにデフォルト値があれば、開発・ステージはデプロイだけで動作させられます。本番でのみデフォルト値を上書きするためにSecretを使います。この機能だけを考えれば、ステージ用のConfigMapを先に要求する必要はありませんでした。ただし、本番と開発で同じキーを使ってはいけません。

4. ここでHMACが担う役割

HMAC-SHA256は、メッセージと秘密鍵から固定長のタグを作ります。キーを持つ側だけが同じタグを再現できます。そのため、URL上で有効期限を1日延ばしたりプールIDを変更したりしても、タグが一致しなければサーバーは拒否します。これは暗号化ではありません。ペイロードはデコードすれば見えます。見えることを防ぐ技術ではなく、見える内容を勝手に改ざんできないようにする技術です。

このプロジェクトのトークンは、概念的にはpayload.signatureの2つの部分で構成されます。payloadはプールIDと有効期限を折りたたんだ後、URL-safe Base64でエンコードした文字列で、signatureはそのpayload文字列に対するHMAC-SHA256を同じ方式でエンコードした文字列です。検証時には、受け取ったpayloadを使って署名を再計算して比較し、その後で有効期限を確認します。先に署名を確認すれば、偽造された有効期限を信じることはありません。

5. 適用の過程

5.1 設定が読み取る名前

Springの設定はおおむね以下のようになります。コロンの後ろは、Podに環境変数がない場合に使われるデフォルト値です。本番ではこのデフォルト値を使わない前提で、同じ名前の環境変数をPodに渡します。

banjang:
  contact-pool:
    invite:
      hmac-secret: ${BANJANG_CONTACT_POOL_INVITE_HMAC_SECRET:dev-only-not-for-prod}
      expire-hours: ${BANJANG_CONTACT_POOL_INVITE_EXPIRE_HOURS:72}

ここで${名前:デフォルト値}の構文をよく混同しました。Kubernetesがymlを直接書き換えてくれるわけではありません。アプリの起動時に環境変数BANJANG_CONTACT_POOL_INVITE_HMAC_SECRETがあればその値になり、なければコロンの後ろの値になります。Secretオブジェクトのキー名もこの環境変数名と同じにすると、マッピングが単純になります。

5.2 サーバーがトークンを作成する側(概念コード)

以下は、リポジトリに存在しない状態を前提にした概念コードです。実際のクラス名やパッケージはチームのコードと異なる場合があります。重要なのは、エンコーディングがURL-safeでパディングなしであること、そして署名対象が「すでにエンコードされたpayload文字列」であることです。

public String issue(String poolId, Instant expiresAt) {
    String payloadJson = "{"p":"" + poolId + "","e":" + expiresAt.getEpochSecond() + "}";
    String payload = base64UrlNoPad(payloadJson.getBytes(StandardCharsets.UTF_8));
    
Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    String signature = base64UrlNoPad(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));

    return payload + "." + signature;
}

private static String base64UrlNoPad(byte[] src) {
    return Base64.getUrlEncoder().withoutPadding().encodeToString(src);
}

5.3 サーバーがトークンを検証する側(概念コード)

public InvitePayload verify(String token) {
    String[] parts = token.split("\\.");
    if (parts.length != 2) {
        throw new IllegalArgumentException("malformed token");
    }
    String payload = parts[0];
    String given = parts[1];
    String expected = sign(payload); // 발급과 동일한 HMAC

    if (!MessageDigest.isEqual(
            given.getBytes(StandardCharsets.US_ASCII),
            expected.getBytes(StandardCharsets.US_ASCII))) {
        throw new IllegalArgumentException("bad signature");
    }

    InvitePayload body = decode(payload);
    if (Instant.now().isAfter(body.expiresAt())) {
        throw new IllegalArgumentException("expired");
    }
    return body;
}

文字列の==比較ではなくMessageDigest.isEqualを使うのは、長さや内容が異なっていても比較時間が一方に偏らないようにするための習慣です。招待リンク程度では実務上大きな違いとして感じることはありませんでしたが、署名の比較にはこちらが適しています。有効期限は署名の検証に通った後で確認します。有効期限だけを見て署名を確認しなければ、有効期限を書き換えたトークンを受け入れてしまう可能性があります。

5.4 フロントエンドが行うこと

フロントエンドはキーを知りません。班長の画面で「リンクを作成」を押すと、サーバーがトークンを返し、それをクエリパラメーターとして付けるだけです。作業員がディープリンクからアクセスすると、その文字列を登録APIのbodyに再び入れます。

// 반장: 서버가 준 토큰만 붙인다
const shareUrl = `${origin}/job/jobs/pool-invitations?token=${encodeURIComponent(token)}`;

// 일꾼: 받은 토큰 문자열을 그대로 등록에 낸다
await JobWkrFlowApi.registerContactPoolInvitation({ token });

シートを開くたびに新しいトークンを作るという製品上の決定に従いました。同じトークンを複数の作業員が使えるため、一回限りのものではありません。「新しいトークン」とは、有効期限を新たに設定するという意味に近く、以前のトークンが期限切れ前であれば、両方とも有効になる可能性があります。この点は、製品側ともう一度認識を合わせておくとよいでしょう。

5.5 デプロイ時にキーが実際に渡る経路

開発・ステージでは、ymlのデフォルト値でも機能が動作します。本番では、SecretにBANJANG_CONTACT_POOL_INVITE_HMAC_SECRETキーで値を入れ、DeploymentのenvまたはenvFromで同じ名前をPodに渡します。ConfigMapに秘密鍵を入れてはいけません。ConfigMapは通常、平文で管理されやすいためです。

# 운영 Secret 값 만들기 (실값은 기록하지 않음)
openssl rand -base64 32

# 개념적인 매핑. 키 이름은 yml placeholder와 동일해야 한다
apiVersion: v1
kind: Secret
metadata:
  name: banjang-contact-pool-invite
type: Opaque
stringData:
  BANJANG_CONTACT_POOL_INVITE_HMAC_SECRET: "<openssl 결과>"

ステージ用のConfigMapを先に作ってほしいという要求は、この機能だけを見れば順序が先行していました。本番デプロイ前にSecretでデフォルト値を上書きする作業を分ければ十分です。開発・ステージ・本番で同じ値を使うと、開発から漏れたキーが本番リンクを偽造する材料になります。

6. 適用中に行き詰まった点

6.1 どの文字列を署名対象の基準にするか

JSONを先にHMACしてからBase64にするのか、Base64のpayload文字列をHMACするのかで、一度迷いました。発行側と検証側が同じものを見ればよいのです。私たちは、URLに載るpayload文字列を署名対象にしました。デコード後のJSONバイト列に再び署名すると、JSONのキー順や空白が異なったときに壊れてしまいます。

6.2 標準Base64をそのまま使うとリンクが壊れる

同僚のスニペットは標準Base64でした。クエリに入れると、+が空白として解釈される環境があります。パディングの=も扱いにくいものです。URL-safe、パディングなしに統一して初めて、チャットやSMSで共有してもトークンが壊れなくなりました。

6.3 有効期限を一覧フィルターだけで処理すると回避される

期限切れの招待を一覧に表示しないことと、登録APIが期限切れトークンを拒否することは別です。前者だけでは、以前のリンクをブックマークしていた人がAPIを直接呼び出せます。検証時点のInstant.now()とペイロードの有効期限を比較することが、本来の要件です。

6.4 フロントエンドで再署名する誘惑

有効期限を画面に表示したいからといって、フロントエンドでペイロードを開けるようにすることはできます。開いて見ることと、署名することは別です。ペイロードは見えるように作るものであり、修正して再署名するキーをフロントエンドに置いてはいけません。有効期限が必要なら、サーバーが発行レスポンスにexpiresAtを一緒に返せばよいのです。

7. 結果として何が変わったのか

  • 招待リンクに有効期限が設定され、有効期限が切れた後の登録はサーバーで拒否されます。

  • フルIDだけを修正したURLは、署名が一致しないため通過しません。

  • フロントエンドのストレージに秘密鍵はありません。トークン文字列だけを渡します。

  • 運用キーは、環境変数名とSecretのキーを合わせる作業として分離されました。

定量的に攻撃が何件減ったかは、この記事には記載しません。この作業の結果は、「有効期限・改ざんをサーバーが判定できるリンク」ができたことです。以前のリンク形式との互換性を一時的に維持するかどうかはデプロイ戦略の問題であり、旧リンクをどの程度受け付けるかは別途決めることでした。

8. この作業をして新たに分かったこと

HMACを使うコードとキーをどこに置くかは、別の問題です。アプリはすでにアルゴリズムを使っており、運用時に変わるのはポッドの環境変数です。コードをさらに書くことと、クラスター上の名前を合わせることを一度に考えると、Secretを作れば終わりだという錯覚に陥ります。

フロントエンドは署名の計算担当ではありません。トークン文字列を渡すだけです。キー1個がリンク1個を意味するわけでもありません。キーは環境ごとに1つで、リンクごとに異なるのはペイロードです。この機能だけを見る限り、ステージ用のConfigMapを先に要求する必要はありませんでした。運用時にデフォルト値を上書きする理由は別にあります。

共通のUtilを作る前に、ドメインサービスにすでにHMACがあるかを探す習慣がつきました。エンコーディングが違うだけでも、「ユーティリティがないから」ではなく、「既存のものとは異なるアルファベットを使おうとしていた」というケースが多くあります。

9. 実務で残しておきたいこと

  • 署名トークンを使う場合でも、クライアントに秘密鍵を入れません。

  • 運用用のSecretを作るときは、ymlが参照する環境変数名をそのまま使います。開発・ステージングのデフォルト値とは異なる値を設定します。

  • リンクごとにキーを作ろうとすると、運用できなくなります。共有キーと異なるペイロードで十分です。

  • URLに入れるHMACは、+、/、=によってリンクが壊れる可能性があるため、まずURL-safeでパディングなしであることを確認します。

  • 有効期限の確認は、一覧のフィルタリングではなく、検証時点で行います。

  • 学習ノートやWikiに実際のキーの値を貼り付けません。作成コマンドだけを残します。

キーの生成は、たとえばopenssl rand -base64 32の結果をSecretの値に設定するという形で行います。人が文章を長く書く方式は使いませんでした。

10. あわせて見るとよい概念

  • URL-safe Base64:リンクやクエリに入れても文字列が壊れないようにするエンコーディング

  • 環境変数とKubernetes Secret / ConfigMap:秘密情報はSecret、平文の設定はConfigMap

  • トークンの有効期限:検証時点での拒否と、一覧画面での非表示は異なる

  • 共有リンクと署名付き招待リンク:誰が開いてもよいアドレスなのか、改ざんを防ぐためのアドレスなのか

11. おわりに

招待リンクに有効期限を付ける作業は、画面の文言を修正するだけのように見えました。実際に取り組んでみると、「誰が署名するのか」「キーはどこにあるのか」「リンクごとにキーが異なるのか」という問題が一度に付いてきました。HMACそのものよりも、秘密情報をクライアントに置かない流れと、環境変数名を一文字も間違えずに合わせることのほうが、より長く印象に残りました。

同じ要件が再び出てきても、最初にアルゴリズムを選ぶことはしません。まず、リンクが共有されるのか、有効期限を誰が判定するのか、環境ごとにキーはいくつ必要なのかを書き出し、その後でHMACにするかJWTにするかを選びます。この記事のコードはリポジトリに登録していない状態なので、概念レベルのものです。チームのコードのクラス名と設定キーは、デプロイ済みのブランチを基準に改めて確認するとよいでしょう。

Mina

Site footer