パスキーがパスワードに代わる: 仕組みと統合方法
1. 概要
パスワードには3つの大きな問題があります。推測しやすい、複数の場所で再利用されている、フィッシングサイトに入力されやすいという点です。パスキーはこの3つをすべて公開鍵暗号で解決します。サーバーはもはやユーザーの秘密を保持する必要がありません。
この記事ではパスキーが何かを説明し、登録フローとログインフローがどのように動作するかを見ていきます。その後、Spring Securityを使用してSpring Boot 3.xアプリケーションでパスキーを有効にする方法を説明します。記事の最後に、一般的なエラーとパスキーを使用すべきでないケースについて説明します。
2. パスキーとは?
パスキーはWebAuthn credential、つまりFIDO2標準に準拠したcredentialです。本質的には、これは鍵ペアです。
- 秘密鍵はユーザーのデバイス(スマートフォン、ノートパソコン、USBセキュリティキー)またはパスワードマネージャー上に保存されます。この鍵は決してそこから離れることはありません。
- 公開鍵は登録時にサーバーに送信されます。サーバーはこの鍵をアカウントとともに保存します。
各鍵ペアはRelying Party(RP)、つまりあなたのウェブサイトまたはアプリケーションに、RP IDを通じてバインドされます。RP IDは通常、ドメイン名です。例えばshop.example.com。
パスキーには主に2つのタイプがあります。
| タイプ | 特徴 |
|---|---|
| Synced passkey | オペレーティングシステムのクラウドアカウントまたはパスワードマネージャーを介して同期されます。デバイスを失ってもリカバリできます。 |
| Device-bound passkey | 単一のデバイス(例:ハードウェアセキュリティキー)にのみ存在します。より安全ですが、デバイスを失うと鍵も失われます。 |
ユーザーはデバイスの指紋、顔、またはPINでパスキーをロック解除します。バイオメトリックスデータはローカルロック解除にのみ使用されます。サーバーには送信されません。
3. パスキーの仕組み
3.1. 登録(registration)
- サーバーはランダムなchallengeを生成します。サーバーはchallengeをRP情報とユーザー情報とともにブラウザに送信します。
- ブラウザは
navigator.credentials.create()を呼び出します。Authenticatorはその正確なRP IDのための新しい鍵ペアを生成します。 - ブラウザは公開鍵、credential ID、署名されたデータをサーバーに送信します。
- サーバーはchallengeとoriginを確認してから公開鍵を保存します。
3.2. ログイン(authentication)
- サーバーは新しいchallengeを生成します。
- ブラウザは
navigator.credentials.get()を呼び出します。Authenticatorは秘密鍵でchallengeに署名します。 - サーバーは保存された公開鍵を使用して署名を検証します。
コアの部分は「秘密鍵で署名し、公開鍵で検証する」です。以下の純粋なJavaコードがこのステップをシミュレートしています。
import java.security.*;
public class PasskeyConcept {
public static void main(String[] args) throws Exception {
// デバイスが登録時に鍵ペアを生成する(EC P-256、WebAuthnで一般的なES256に対応)
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(256);
KeyPair deviceKeys = generator.generateKeyPair();
PublicKey storedOnServer = deviceKeys.getPublic();
// サーバーはログイン用にランダムなchallengeを生成する
byte[] challenge = randomBytes();
// デバイスは秘密鍵でchallengeに署名する
Signature signer = Signature.getInstance("SHA256withECDSA");
signer.initSign(deviceKeys.getPrivate());
signer.update(challenge);
byte[] signature = signer.sign();
// サーバーは保存された公開鍵で検証する
System.out.println("正しいチャレンジ: " + verify(storedOnServer, challenge, signature));
System.out.println("異なるチャレンジ: " + verify(storedOnServer, randomBytes(), signature));
}
static boolean verify(PublicKey key, byte[] data, byte[] signature) throws Exception {
Signature verifier = Signature.getInstance("SHA256withECDSA");
verifier.initVerify(key);
verifier.update(data);
return verifier.verify(signature);
}
static byte[] randomBytes() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return bytes;
}
}
結果:
正しいチャレンジ: true
異なるチャレンジ: false
この結果から、2つのことが推測できます。
- サーバーは
storedOnServer、つまり公開鍵だけを保持しています。攻撃者がデータベースを侵害してもログインに使用できません。 - 署名はそのときのchallengeにのみ有効です。古い署名を傍受して再送信(リプレイ攻撃)しても、攻撃者は検証ステップを通過できません。
実際のWebAuthnでは、署名されたデータにはページのoriginも含まれています。ブラウザがoriginを自動入力し、ウェブページはそれを偽造できません。したがって、ユーザーが誤ってexamp1e.comにアクセスした場合、ブラウザはexample.comのパスキーを使用しません。フィッシングはプロトコルレベルでブロックされ、ユーザーの注意力に依存しません。
4. パスキーが注目されている理由
- 設計の時点からフィッシング対策。 パスワードとOTPはどちらも偽のサイトに入力される可能性があります。パスキーはそうではありません。
- サーバーに秘密がない。 データベースが侵害されてもログイン情報は漏洩しません。
- より優れたUX。 ユーザーは指を置くだけで、パスワードを覚えたりSMSを待つ必要はありません。
- エコシステムが準備完了。 主流のオペレーティングシステム、ブラウザ、パスワードマネージャーはすべてパスキー同期をサポートしています。おかげで、デバイスの紛失はもはや以前のような大きな障害ではなくなっています。
- Frameworkによるサポート。 暗号検証部分を自分で書く必要はなく、実装は非常にエラーが起きやすいものです。
5. Spring Securityへのパスキーの統合
Spring Security 6.4以降(Spring Boot 3.4以降に付属)、frameworkにはwebAuthn() DSLが組み込まれています。内部では、検証部分はWebAuthn4Jライブラリを使用します。
5.1. 依存関係
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.webauthn4j</groupId>
<artifactId>webauthn4j-core</artifactId>
<version>${webauthn4j.version}</version>
</dependency>
</dependencies>
webauthn4j.versionプロパティを、使用しているSpring Securityと互換性のあるバージョンで宣言します。Spring Securityのドキュメントには、テスト済みのバージョンが明示されています。
5.2. SecurityFilterChainの設定
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.webAuthn(webAuthn -> webAuthn
.rpName("Nón Lá Shop")
.rpId("localhost")
.allowedOrigins("http://localhost:8080")
);
return http.build();
}
@Bean
UserDetailsService userDetailsService() {
UserDetails customer = User.withUsername("minh")
.password("{noop}matkhau-tam")
.roles("CUSTOMER")
.build();
return new InMemoryUserDetailsManager(customer);
}
}
各部分の意味:
rpName: OSのパスキーダイアログに表示される名前。rpId: パスキーにバインドされるドメイン名。この値はschemeやport を含みません。本番環境では、これはshop.example.comになります。allowedOrigins: 許可されたorigin。Originはschemeとportの両方で正確に一致する必要があります。formLogin: 保持されたままです。ユーザーはパスキーを登録するために最初にパスワードでログインする必要があります。パスキーは既存のアカウントに追加されるcredentialです。
アプリケーションを実行する際、Spring Securityは以下のendpointを自動的に作成します。
GET /webauthn/register パスキー管理/登録ページ(デフォルト)
POST /webauthn/register/options 登録チャレンジを生成
POST /webauthn/register 新しい公開鍵を保存
POST /webauthn/authenticate/options ログインチャレンジを生成
POST /login/webauthn 署名を検証してログイン
デフォルトログインページには「Sign in with a passkey」ボタンが追加されます。テストフローは4つのステップで構成されています。
minh/matkhau-tamでログインします。/webauthn/registerを開いてパスキーを作成します。- ログアウトします。
- パスキーで再度ログインします。
5.3. Credentialストレージ
デフォルトでは、Spring Securityはcredentialをメモリ内に保存します。アプリケーションをリスタートするとすべてのパスキーが失われます。本番環境では、次の2つのインターフェースを実装するbeanを提供する必要があります。
UserCredentialRepository: 公開鍵、credential ID、署名countを保存します。PublicKeyCredentialUserEntityRepository: あなたのユーザーをWebAuthnのuser handleにマップします。
最新のSpring Securityバージョンでは、これら2つのインターフェースのJDBC実装があらかじめ用意されています。JPAを使用して自分で実装することもできます。この場合、ユーザーは複数のデバイスで複数のパスキーを持つことが多いため、各credentialは個別のレコードである必要があります。
6. ブラウザ側
デフォルトログインページを使用する場合、Spring SecurityはJavaScriptを処理します。カスタムUIの場合、ログインフローは以下のようになります。
async function loginWithPasskey() {
const csrf = document.querySelector('meta[name="_csrf"]').content;
// 1. サーバーからチャレンジをリクエスト
const optionsRes = await fetch('/webauthn/authenticate/options', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrf }
});
const options = PublicKeyCredential.parseRequestOptionsFromJSON(await optionsRes.json());
// 2. ブラウザがダイアログを表示し、authenticatorがチャレンジに署名
const credential = await navigator.credentials.get({ publicKey: options });
// 3. 署名をサーバーに送信して検証
const res = await fetch('/login/webauthn', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf },
body: JSON.stringify(credential.toJSON())
});
if (res.ok) window.location.href = '/';
}
コード内のステップ:
- ステップ1はチャレンジを取得します。これは
POSTであるため、リクエストにCSRFトークンを含める必要があります。 parseRequestOptionsFromJSONはbase64urlフィールドをArrayBufferに変換します。古いブラウザはこの関数をサポートしていない可能性があります。その場合、ヘルパーライブラリまたは手動デコードが必要です。- ステップ3は結果をサーバーに送信します。JSON形式はサーバーが期待する形式と一致する必要があります。Spring Securityのデフォルトログインページ内のスクリプトが比較する最良のリファレンスです。
7. テスト
unit testで実際のauthenticatorをシミュレートすることは簡単ではありません。ただし、サーバー側の重要な部分をテストすることはできます。endpointがchallengeを作成し、毎回異なるchallengeを返すことです。
@SpringBootTest
@AutoConfigureMockMvc
class PasskeyEndpointTest {
@Autowired
MockMvcTester mvc;
@Test
void authenticationOptions_containRpIdAndFreshChallenge() {
String first = requestChallenge();
String second = requestChallenge();
assertThat(first).isNotBlank();
assertThat(second).isNotEqualTo(first);
}
@Test
void authenticationOptions_useConfiguredRpId() {
assertThat(mvc.post().uri("/webauthn/authenticate/options").with(csrf()))
.hasStatusOk()
.bodyJson()
.extractingPath("$.rpId")
.isEqualTo("localhost");
}
private String requestChallenge() {
MvcTestResult result = mvc.post()
.uri("/webauthn/authenticate/options")
.with(csrf())
.exchange();
assertThat(result).hasStatusOk();
return JsonPath.read(result.getResponse().getContentAsString(), "$.challenge");
}
}
MockMvcTester(Spring Framework 6.2以降)はAssertJスタイルのアサーションを記述できます。csrf()はSecurityMockMvcRequestPostProcessorsから取得します。最初のテストはchallengeが再利用されていないことを確認します。これはリプレイ攻撃対策メカニズムです。2番目のテストはrpIdが正しく設定されていることを確認します。これはデプロイ時の最も一般的な設定エラーです。
エンドツーエンドテストの場合、ブラウザのvirtual authenticatorを使用します。Chrome DevToolsにはWebAuthnタブがあり、WebDriver/Playwrightもこの機能をサポートしています。
8. 一般的なエラーと使用すべきでないケース
8.1. 一般的なエラー
rpIdまたはallowedOriginsが間違っている。rpIdにhttps://を入力したり、originでportを忘れたりすると、ブラウザまたはサーバーが拒否します。これら2つの値は環境に応じてapplication.yamlに分けるべきです。- HTTPSを使用していない。 WebAuthnはセキュアコンテキストでのみ実行されます。
localhostは唯一の例外です。 - ドメイン名を変更する。 パスキーはRP IDにバインドされています。プライマリドメインを変更すると、すべての古いパスキーは使用できなくなります。
- 本番環境でメモリ内ストレージを使用する。 リスタートするとユーザーがパスキーを失います。
- 1つのパスキーのみの登録を許可する。 ユーザーは複数のデバイスのために複数のパスキーが必要です。リストページを用意し、各パスキーの削除を許可してください。
注意: パスキーは、アカウント回復フローも強力な場合にのみ強力です。「パスキーを忘れた」がメール経由でリンクを送信するだけの場合、攻撃者はパスキーではなくメールを狙います。
8.2. 使用すべきでないとき(または単独での使用は避けるべき)
- 古いデバイスまたは共有デバイス上のユーザー(レジ用のコンピュータやキオスクなど)。このような場合、バックアップ方法を保持する必要があります。
- ブラウザまたはアプリに基づいていないシステム(サービス間API、CLIまたはバックグラウンドジョブなど)。これらのケースではOAuth2 client credentials、mTLS、またはAPI keyを使用すべきです。
- ハードウェアキーのみを受け入れるコンプライアンス要件。 同期されたパスキーは要件を満たさない可能性があります。device-bound passkeyとattestation確認が必要です。
- チームがまだアカウント回復サポートプロセスを用意していない。 パスキーを補足的なオプションとして最初にデプロイし、その後パスワードを廃止する方向で進めるべきです。
9. まとめ
パスキーは共有秘密(パスワード)を、ドメインにバインドされた公開鍵ペアで置き換えます。そのおかげで、パスキーは1つのメカニズムでフィッシング、credential stuffing、データベース漏洩のリスクを排除します。Spring Securityを使用すれば、webAuthn()をSecurityFilterChainに追加し、rpIdとallowedOriginsを正しく設定して、メモリ内credentialストレージを永続的なストレージに置き換えるだけです。
最も安全な実装方法は段階的に進めることです。既存のアカウントにパスキーを追加できるようにしてから、堅牢なアカウント回復フローを設計してから、パスワードログインを無効にすることを検討してください。