コードから追うパスキー認証の仕組み|WebAuthnの処理フローと検証ロジック

コードから追うパスキー認証の仕組み

1. はじめに

近年、パスキー(Passkey)に対応するサービスが増え、パスワードに代わる認証方式として注目を集めています。Laravelでも公式の laravel/passkeys パッケージが提供されており、アプリケーションへ比較的容易に導入できるようになりました。

一方で、私自身は「パスキーは安全な認証方式」ということは知っていたものの、従来のパスワード認証と何が違うのか、なぜ安全と言われているのか、認証時に内部でどのような処理が行われているのかまでは理解できていませんでした。

そこで本記事では、Laravelで実際にパスキー認証を実装しながら、WebAuthnの認証の仕組みについて理解を深めることを目的としました。実装だけで終わらせるのではなく、Laravel PasskeysやWebAuthnライブラリのソースコードを手掛かりに、認証時にどのような処理が行われているのかを確認していきます。

 

2. パスキー認証とは

パスキー認証は、WebAuthnと公開鍵暗号方式を利用したパスワードレス認証です。従来のようにパスワードをサーバーへ送信することなく、安全に本人確認を行えます。

 

WebAuthnの主要用語

本記事を読み進めるにあたり、WebAuthnに登場する主要な用語を以下に整理します。

用語概要
Relying Party (RP)認証を要求・利用するWebサーバーやサービス(本記事ではLaravelアプリケーション)。
RP IDRelying Partyを識別するためのドメイン名など。フィッシング攻撃防止に活用される。
Authenticator秘密鍵を管理し署名を生成する機器や仕組み(Windows Hello、スマホの安全な領域、パスワードマネージャー、セキュリティキー等)。
CredentialAuthenticator内に作成される認証情報(公開鍵・秘密鍵のペアやIDなど)。
Challengeリプレイ攻撃を防ぐために、サーバーが認証のたびに発行する一度限りのランダムなデータ。
Assertion認証時にAuthenticatorが秘密鍵で署名し、認証証明としてサーバーへ返送するデータ。

 

認証の流れ

パスキー認証では、登録認証の2つのフェーズがあります。

登録時には、Authenticatorがパスキーの秘密鍵と公開鍵のペアを生成します。秘密鍵はAuthenticator内に安全に保存され、公開鍵のみがサーバーへ登録されます。指紋認証や顔認証、端末のPINは、Authenticatorによる認証処理を許可するために使用されます。

認証時には、登録済みの公開鍵とAuthenticator内の秘密鍵を利用して本人確認を行います。認証の流れを図化すると、以下のようになります。
認証の流れ

 

https://www.w3.org/TR/webauthn/#fig-authentication から引用

  1. サーバーは認証要求(PublicKeyCredentialRequestOptions)を生成し、一度しか利用できないランダムな値であるChallengeをブラウザへ返します。
  2. ブラウザは受け取ったChallengeをAuthenticatorへ渡し、認証を要求します。
  3. Authenticatorは利用者本人であることを確認した後、保持している秘密鍵を使用してChallengeに関連するデータへ署名を生成します。このとき、秘密鍵がAuthenticatorの外へ送信されることはありません。
  4. Authenticatorは生成した署名をブラウザへ返します。
  5. ブラウザは署名や認証に必要な情報(clientDataJSONauthenticatorDatasignatureなど)をサーバーへ送信します。
  6. サーバーは登録時に保存した公開鍵を使用して署名を検証し、問題がなければ認証成功となります。

このように、サーバーは公開鍵のみを保持し、秘密鍵は常にAuthenticator内に保持されたまま認証が行われます。そのため、サーバーへパスワードや秘密鍵を送信する必要がなく、漏えいリスクを大きく低減できる点がパスキー認証の大きな特徴です。

パスワード認証との主な違いをまとめたのが以下です。

項目パスワード認証パスキー認証
サーバーへ送信するものパスワード署名を含む認証結果
サーバーが保持するものパスワードハッシュ公開鍵
利用者が覚えるものパスワード基本的に不要
フィッシング耐性

本記事では、この認証フローを踏まえた上で、まずLaravelでパスキー認証を実装します。その後、各ステップがLaravel PasskeysおよびWebAuthnライブラリでどのように実装されているのかを確認しながら、WebAuthnの認証の仕組みを理解していきます。

 

3. Laravelでパスキー認証を実装する

Laravelでは、パスキー認証を実装するためのライブラリがいくつか公開されています。本記事では、Laravel公式が提供する laravel/passkeys を利用して実装を行いました。

今回は、laravel newコマンドでスターターキットを利用して新規プロジェクトを作成し、プロジェクト作成時のオプションで以下のようにPasskeysを有効化しました。

Laravelでパスキー認証を実装する

 

スターターキットのセットアップ時にPasskeysを有効化すると、パスキーの登録・認証に必要なルーティングやコントローラ、フロントエンドのコンポーネントなどが自動で追加されます。そのため、特別な実装を行わなくても、パスキー認証を試せる状態になります。

実際にユーザー登録を行い、パスキーを登録した後、以下のようにパスキーによるログインが正常に行えることを確認しました。

パスキーによるログイン①

 

パスキーによるログイン②

 

パスキーによるログイン③

 

4. WebAuthnの認証の仕組みを理解する

ここからは、パスキー認証時に内部でどのような処理が行われているのかを確認していきます。
認証の流れは先ほども添付した以下の図の通りです。

認証の流れ

 

今回確認する流れは、次の4つです。

  1. サーバーが認証に必要な情報(Challengeなど)を生成する
  2. ブラウザがWebAuthn APIを利用してAuthenticatorへ認証を要求する
  3. Authenticatorが秘密鍵を用いて署名を生成する
  4. サーバーが認証結果を検証する

それでは、まずは認証時にサーバーが生成するChallengeについて見ていきます。

 

4.1 サーバーが認証に必要な情報(Challengeなど)を生成する

認証フローの最初のステップでは、サーバーが認証に必要な情報を生成し、ブラウザへ返します。

WebAuthnでは、この認証要求を PublicKeyCredentialRequestOptions と呼びます。PublicKeyCredentialRequestOptions は、認証時にブラウザへ渡す情報をまとめたオブジェクトであり、W3C WebAuthn仕様で定義されています。
https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptions

PublicKeyCredentialRequestOptions には challenge、rpId、allowCredentials などの情報が含まれますが、ここでは認証の安全性を支える重要な要素である challenge に着目します。

MDNでは、challenge について「サーバーが生成する暗号学的なチャレンジであり、Authenticatorによって署名され、その署名は認証結果の一部としてサーバーへ返される」と説明されています。
https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialRequestOptions#challenge

Challengeは認証のたびに生成される一度限りのランダムな値です。Authenticatorは、このChallengeを含む認証データに対して署名を生成します。

もし毎回同じChallengeを利用できると、過去の認証結果をそのまま再送することで認証できてしまう可能性があります。Challengeを毎回新しく生成することで、認証結果はそのChallengeに対してのみ有効となり、過去の認証結果を再利用した攻撃(リプレイ攻撃)を防ぐことができます。

Laravel Passkeysでも、認証開始時に PublicKeyCredentialRequestOptions を生成し、その中でChallengeを作成しています。

参考
https://github.com/laravel/passkeys-server/blob/4f81dfd5f7f983bdfe3ee6b3971c51acaa7844c3/src/Actions/GenerateVerificationOptions.php#L21

/**
 * Generate verification options for passwordless login or user confirmation.
 *
 * @see https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptions
 */
public function __invoke(?PasskeyUser $user = null): PublicKeyCredentialRequestOptions
{
    return PublicKeyCredentialRequestOptions::create(
        challenge: random_bytes(32),
        rpId: Passkeys::relyingPartyId(),
        allowCredentials: $this->allowCredentials($user),
        userVerification: AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED,
        timeout: Passkeys::timeout(),
    );
}

生成した PublicKeyCredentialRequestOptions はブラウザへ返却されるとともに、サーバー側ではセッションへ保存されます。後続の認証処理では、ブラウザから返された認証結果が、この認証要求に対するものであることを検証するために利用されます。

次は、ブラウザが受け取った PublicKeyCredentialRequestOptions を利用して、Authenticatorへ認証を要求する処理を見ていきます。

 

4.2 ブラウザがWebAuthn APIを利用してAuthenticatorへ認証を要求する

前節でサーバーから PublicKeyCredentialRequestOptions を受け取ると、次はブラウザがAuthenticatorへ認証を要求します。

WebAuthnでは、ブラウザが提供する WebAuthn API を利用してAuthenticatorとやり取りを行います。開発者はこのAPIを利用することで、指紋認証や顔認証、セキュリティキーなどを利用した認証を開始できます。

認証時に利用されるAPIが navigator.credentials.get() です。詳細はMDNで公開されています。
https://developer.mozilla.org/ja/docs/Web/API/CredentialsContainer/get

Laravel Passkeysでは、Passkeys.verify() の内部で startAuthentication() が呼び出され、その中で navigator.credentials.get() を実行しています。

参考
https://github.com/MasterKale/SimpleWebAuthn/blob/b2f39ba6380d34d4b2625ba16debf2f177be0655/packages/browser/src/methods/startAuthentication.ts#L106

/**
 * Begin authenticator "login" via WebAuthn assertion
 *
 * @param optionsJSON Output from **@simplewebauthn/server**'s `generateAuthenticationOptions()`
 * @param useBrowserAutofill (Optional) Initialize conditional UI to enable logging in via browser autofill prompts. Defaults to `false`.
 * @param verifyBrowserAutofillInput (Optional) Ensure a suitable `<input>` element is present when `useBrowserAutofill` is `true`. Defaults to `true`.
 */
export async function startAuthentication(
  options: {
    optionsJSON: PublicKeyCredentialRequestOptionsJSON;
    useBrowserAutofill?: boolean;
    verifyBrowserAutofillInput?: boolean;
  },
): Promise<AuthenticationResponseJSON> {
  // 中略
 
  // Wait for the user to complete assertion
  let credential;
  try {
    credential = (await navigator.credentials.get(getOptions)) as AuthenticationCredential;
  } catch (err) {
    throw identifyAuthenticationError({ error: err as Error, options: getOptions });
  }
  
  // 省略
}

ここで渡している getOptions.publicKey には、前節でサーバーから受け取った PublicKeyCredentialRequestOptions が設定されています。

navigator.credentials.get() が呼び出されると、ブラウザは PublicKeyCredentialRequestOptions をAuthenticatorへ渡し、認証を要求します。利用者は、端末に設定された指紋認証や顔認証、PINなどを用いて本人確認を行います。

本人確認に成功すると、Authenticatorは保持している秘密鍵を利用して認証処理を行い、その結果をブラウザへ返します。このとき、秘密鍵がAuthenticatorの外部へ送信されることはありません。

ここでブラウザへ返される認証結果には、署名をはじめとする認証に必要な情報が含まれています。
次節では、Authenticatorがどのような認証データを生成し、ブラウザへ返しているのかを見ていきます。

 

4.3 Authenticatorが秘密鍵を利用して署名を生成する

利用者が指紋認証や顔認証、PINなどによる本人確認を完了すると、Authenticatorは保持している秘密鍵を利用して認証データへ署名を生成します。

ここで重要なのは、秘密鍵は登録時にAuthenticator内で生成され、その後もAuthenticatorの外部へ送信されることはないという点です。そのため、ブラウザやサーバーが秘密鍵そのものを取得することはできません。

署名の対象となる認証データには、前節でサーバーから受け取ったChallengeをはじめ、認証に必要な情報が含まれています。これにより、生成された署名は今回の認証要求に対してのみ有効なものとなります。

認証処理が完了すると、Authenticatorはブラウザへ認証結果を返します。
navigator.credentials.get() の戻り値として認証結果を受け取り、サーバーへ送信しています。
取得した認証結果には、主に次の情報が含まれています。

  • clientDataJSON
  • authenticatorData
  • signature

このうち、signature がAuthenticatorの秘密鍵によって生成された署名です。一方、clientDataJSON や authenticatorData には、サーバーが認証結果を検証するために必要な情報が格納されています。

ブラウザはこれらの認証結果をサーバーへ送信しますが、送信されるのは署名や認証データのみであり、秘密鍵がAuthenticatorの外部へ送信されることはありません。

次節では、サーバーがこれらの認証結果を受け取り、登録済みの公開鍵を利用してどのような検証を行っているのかを見ていきます。

 

4.4 サーバーが認証結果を検証する

ブラウザから認証結果を受け取ると、最後にサーバーがその内容を検証します。

WebAuthnでは、サーバーが認証結果をどのように検証するかが Verifying an Authentication Assertion として仕様で定義されています。まずはこの仕様を踏まえた上で、Laravel Passkeysがどのように実装しているのかを見ていきます。

参考
W3C WebAuthn Level 3 - Verifying an Authentication Assertion
https://www.w3.org/TR/webauthn-3/#sctn-verifying-assertion

仕様では、ChallengeやRP ID(Relying Party ID)、署名、署名カウンタ(signCount)など、認証結果が正当なものであることを確認するための検証手順が定義されています。

Laravel Passkeysでは、/passkeys/login に送信された認証結果を VerifyPasskey クラスで受け取り、認証処理を開始します。ただし、これらの検証をLaravel Passkeys自身が実装しているわけではありません。実際の検証処理は、WebAuthnライブラリ(web-auth/webauthn-lib)へ委譲されています。

ここからは、まず VerifyPasskey がどのようにWebAuthnライブラリへ処理を委譲しているのかを確認し、その後にライブラリ内部でどのような検証が行われているのかを追っていきます。

 

4.4.1 VerifyPasskeyからWebAuthnライブラリへ処理を委譲する

ブラウザから送信された認証結果は、Laravel Passkeysの VerifyPasskey クラスで受け取られます。

参考
https://github.com/laravel/passkeys-server/blob/4f81dfd5f7f983bdfe3ee6b3971c51acaa7844c3/src/Actions/VerifyPasskey.php#L40

VerifyPasskey__invoke() メソッドでは、認証結果から AuthenticatorAssertionResponse を取得した後、validate() メソッドを呼び出しています。

$source = $this->validate($response, $passkey, $options);

validate() メソッドでは、登録時に保存した公開鍵などを保持する CredentialRecord を生成し、WebAuthnライブラリが提供する assertionValidator()->check() を呼び出しています。

return WebAuthn::assertionValidator()->check(
    credentialRecord: $source,
    authenticatorAssertionResponse: $response,
    publicKeyCredentialRequestOptions: $options,
    host: Passkeys::relyingPartyId(),
    userHandle: $source->userHandle,
);

ここで渡されている credentialRecord には登録済みの公開鍵が、publicKeyCredentialRequestOptions には認証開始時に生成したChallengeなどの情報が含まれています。

つまり、Laravel Passkeysは認証処理の入口を担当し、実際の認証結果の検証はWebAuthnライブラリへ委譲しています。
次節では、この assertionValidator()->check() の内部で、どのように署名が検証されているのかを確認します。

 

4.4.2 CheckSignatureで公開鍵による署名検証を行う

assertionValidator()->check() の内部では、CeremonyStepManager によって複数の検証処理が順番に実行されます。

参考

その中で、署名の検証を担当しているのが CheckSignature クラスです。
CheckSignature では、登録時に保存した公開鍵(CredentialRecord)を取得し、Authenticatorから返された clientDataJSONauthenticatorData をもとに署名の検証対象となるデータを作成しています。

$getClientDataJSONHash = hash(
    'sha256',
    $authenticatorResponse->clientDataJSON->rawData,
    true
);

$dataToVerify =
    $authenticatorResponse->authenticatorData->authData .
    $getClientDataJSONHash;

最後に、公開鍵を利用して署名を検証しています。

$algorithm->verify(
    $dataToVerify,
    $coseKey,
    $signature
);

この検証に成功した場合、サーバーは「登録時に対応する秘密鍵で署名された認証結果である」と判断できます。一方、署名が改ざんされていたり、登録済みの公開鍵と一致しなかった場合は、認証は失敗します。

このように、パスキー認証では秘密鍵がAuthenticatorの外部へ送信されることなく、登録済みの公開鍵だけを利用して認証結果の正当性を確認しています。これが、パスワードを送信することなく本人確認を実現できる理由の一つです。

 

5. WebAuthn認証で行われるその他の検証

前節では、CheckSignature による公開鍵署名の検証処理を確認しました。
しかし、WebAuthnの認証処理では、署名が正しいことだけを確認して認証成功とはなりません。

assertionValidator()->check() の内部では、CeremonyStepManager によって複数の検証処理が順番に実行されます。
これは、WebAuthnが単なる署名認証ではなく、認証要求全体の正当性を確認する仕組みになっているためです。

ここでは、署名検証以外に行われている主な検証について確認します。

 

Challengeの一致確認

WebAuthnでは、認証開始時にサーバーがランダムなChallengeを生成します。
このChallengeは一度限り利用される値であり、認証要求と認証結果を紐付ける役割があります。
Authenticatorから返された clientDataJSON には、このChallengeが含まれています。

サーバーは、認証開始時に保存していたChallengeと、Authenticatorから返されたChallengeが一致するか確認します。
これにより、過去に取得した認証情報を再利用するリプレイ攻撃を防止しています。

 

RP ID Hashの確認

WebAuthnでは、認証情報がどのWebサイト向けに生成されたものかを確認します。
Authenticatorから返される authenticatorData には、RP IDをハッシュ化した値が含まれています。
サーバーは、この値が自身のRP IDから生成したハッシュ値と一致するか確認します。

例えば、攻撃者が偽サイトを用意して認証情報を取得しようとしても、別のRP IDに対して生成された認証情報は正しいものとして扱われません。
この仕組みにより、WebAuthnはフィッシング攻撃への耐性を持っています。

 

User Presence / User Verificationの確認

WebAuthnでは、Authenticatorによるユーザー操作の有無も確認できます。
authenticatorData には、以下のようなフラグが含まれています。

  • User Presence(UP)
  • User Verification(UV)

User Presenceは、ユーザーが認証操作を実行したことを示します。
例えば、セキュリティキーのタッチ操作などが該当します。

User Verificationは、端末の生体認証やPIN入力などによって本人確認が行われたことを示します。
アプリケーション側で userVerification: required を指定している場合、サーバーはUVフラグが有効になっていることを確認します。

 

Signature Counterの確認

Authenticatorは認証処理を実行するたびにカウンター値を更新します。
サーバーは、認証時に送信されたカウンター値と、前回保存していた値を比較します。

通常、この値は認証ごとに増加するため、以前取得した認証情報が再利用された場合に検知できる可能性があります。
ただし、Authenticatorの種類によってはカウンターをサポートしていない場合もあるため、現在では補助的な検証項目として扱われています。

このように、WebAuthnでは署名検証だけではなく、Challenge、RP ID、ユーザー操作状態など複数の情報を組み合わせて認証結果を検証しています。
Laravel Passkeysでは、これらの複雑な検証処理をWebAuthnライブラリへ委譲しているため、アプリケーション側では仕様を意識せずに安全なパスキー認証を実装できます。

 

6. まとめ

今回はLaravel Passkeysを利用してパスキー認証を実装し、認証処理が内部でどのように行われているのかを確認しました。

パスキー認証では、認証開始時にサーバーがChallengeを生成し、そのChallengeを利用してAuthenticatorが秘密鍵による署名を生成します。
その後、サーバー側では登録済みの公開鍵を利用して署名を検証することで、対応する秘密鍵を保持するAuthenticatorによる認証であることを確認しています。

Laravel Passkeysでは、アプリケーション側では認証処理の入口となる処理を提供し、実際のWebAuthn仕様に基づいた検証処理はWebAuthnライブラリへ委譲しています。
内部では、Challengeの一致確認、RP IDの検証、ユーザー検証状態の確認、署名検証など、複数の検証処理によって認証結果の正当性を確認しています。

今回コードを追うことで、パスキー認証は単純に「パスワードをなくす仕組み」ではなく、公開鍵暗号とWebAuthn仕様を組み合わせることで、安全な認証を実現していることが分かりました。
また、Laravel Passkeysのようなライブラリを利用することで、複雑なWebAuthnの仕様を意識しながらも、アプリケーション側では比較的シンプルな実装でパスキー認証を導入できます。

今後パスキー認証を導入する際は、単にAPIを利用するだけではなく、Challenge生成から署名検証までの流れを理解しておくことで、より安全な認証設計につなげることができます。

株式会社SPで一緒に働いてみませんか?

SPはエンジニアの成長を大切にする会社です。

ご興味ある方は一度気軽な雰囲気で、カジュアル面談はいかがでしょうか?

どのような課題を
解決したいですか?

株式会社SPでは、お客様の取り組みに寄り添いながら、
課題解決を伴走支援していきます。

まずはお気軽にこちらからお問い合わせください。

お問い合わせ・相談する(無料)