jpskill.com
🛠️ 開発・MCP コミュニティ

webauthn

WebAuthnとパスキーを使って、パスワードなしで安全にログインできるようにするSkill。指紋認証や顔認証をWebアプリに組み込み、より安全で便利な認証フローを実現する際に役立ちます。

📜 元の英語説明(参考)

Implement passwordless authentication with WebAuthn and Passkeys. Use when: adding passkey/biometric login, implementing FIDO2 authentication, replacing password-based login, building touch ID / face ID authentication flows in web apps. Covers browser API, server verification, and simplewebauthn library.

🇯🇵 日本人クリエイター向け解説

一言でいうと

WebAuthnとパスキーを使って、パスワードなしで安全にログインできるようにするSkill。指紋認証や顔認証をWebアプリに組み込み、より安全で便利な認証フローを実現する際に役立ちます。

※ jpskill.com 編集部が日本のビジネス現場向けに補足した解説です。Skill本体の挙動とは独立した参考情報です。

⚡ おすすめ: コマンド1行でインストール(60秒)

下記のコマンドをコピーしてターミナル(Mac/Linux)または PowerShell(Windows)に貼り付けてください。 ダウンロード → 解凍 → 配置まで全自動。

🍎 Mac / 🐧 Linux
mkdir -p ~/.claude/skills && cd ~/.claude/skills && curl -L -o webauthn.zip https://jpskill.com/download/15557.zip && unzip -o webauthn.zip && rm webauthn.zip
🪟 Windows (PowerShell)
$d = "$env:USERPROFILE\.claude\skills"; ni -Force -ItemType Directory $d | Out-Null; iwr https://jpskill.com/download/15557.zip -OutFile "$d\webauthn.zip"; Expand-Archive "$d\webauthn.zip" -DestinationPath $d -Force; ri "$d\webauthn.zip"

完了後、Claude Code を再起動 → 普通に「動画プロンプト作って」のように話しかけるだけで自動発動します。

💾 手動でダウンロードしたい(コマンドが難しい人向け)
  1. 1. 下の青いボタンを押して webauthn.zip をダウンロード
  2. 2. ZIPファイルをダブルクリックで解凍 → webauthn フォルダができる
  3. 3. そのフォルダを C:\Users\あなたの名前\.claude\skills\(Win)または ~/.claude/skills/(Mac)へ移動
  4. 4. Claude Code を再起動

⚠️ ダウンロード・利用は自己責任でお願いします。当サイトは内容・動作・安全性について責任を負いません。

🎯 このSkillでできること

下記の説明文を読むと、このSkillがあなたに何をしてくれるかが分かります。Claudeにこの分野の依頼をすると、自動で発動します。

📦 インストール方法 (3ステップ)

  1. 1. 上の「ダウンロード」ボタンを押して .skill ファイルを取得
  2. 2. ファイル名の拡張子を .skill から .zip に変えて展開(macは自動展開可)
  3. 3. 展開してできたフォルダを、ホームフォルダの .claude/skills/ に置く
    • · macOS / Linux: ~/.claude/skills/
    • · Windows: %USERPROFILE%\.claude\skills\

Claude Code を再起動すれば完了。「このSkillを使って…」と話しかけなくても、関連する依頼で自動的に呼び出されます。

詳しい使い方ガイドを見る →
最終更新
2026-05-18
取得日時
2026-05-18
同梱ファイル
1

📖 Skill本文(日本語訳)

※ 原文(英語/中国語)を Gemini で日本語化したものです。Claude 自身は原文を読みます。誤訳がある場合は原文をご確認ください。

WebAuthn / パスキー

概要

WebAuthn (Web Authentication) を使用すると、ユーザーはパスワードの代わりに生体認証 (Face ID、Touch ID、Windows Hello) またはハードウェアキー (YubiKey) で認証できます。パスキーは、iCloud Keychain、Google Password Manager、または 1Password を介してデバイス間で同期される WebAuthn 認証情報です。

主な概念:

  • Relying Party (RP): あなたのサーバー — 認証情報を検証します
  • Authenticator: デバイス/プラットフォーム (Touch ID、Face ID、YubiKey)
  • Credential: 公開/秘密鍵ペア — 秘密鍵はデバイスから決して離れません
  • Challenge: サーバーが発行するランダムなバイト列 — リプレイ攻撃を防ぎます

セットアップ

@simplewebauthn のインストール

npm install @simplewebauthn/server @simplewebauthn/browser
# Types
npm install -D @types/node

SimpleWebAuthn は、低レベルの CBOR/COSE エンコーディングを抽象化し、ほとんどのエッジケースを処理します。

Relying Party の設定

// config/webauthn.ts
export const RP_NAME = "My App";
export const RP_ID = process.env.RP_ID || "localhost"; // ドメイン、プロトコル/ポートなし
export const ORIGIN = process.env.ORIGIN || "http://localhost:3000";
// RP_ID は ORIGIN のドメインと一致する必要があります
// 本番環境の場合: RP_ID = "myapp.com", ORIGIN = "https://myapp.com"

登録フロー

概要

Client                          Server
  |                                |
  |-- POST /auth/register/begin -->|
  |                                | 1. チャレンジを生成
  |<-- { options } ---------------|
  |                                |
  | 2. navigator.credentials.create(options)
  |    (ユーザーが Touch ID / Face ID をタップ)
  |                                |
  |-- POST /auth/register/finish ->|
  |   { credential }               | 3. 公開鍵を検証して保存
  |<-- { ok: true } --------------|

サーバー: 登録オプションの生成

// routes/auth.ts
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { RP_ID, RP_NAME, ORIGIN } from "../config/webauthn";

// デモ用のインメモリストア。本番環境では DB を使用
const challenges = new Map<string, string>(); // userId → challenge
const credentials = new Map<string, any[]>(); // userId → credentials[]

app.post("/auth/register/begin", async (req, res) => {
  const { userId, username } = req.body;

  // ユーザーの既存の認証情報を取得 (再登録を防ぐため)
  const userCredentials = credentials.get(userId) || [];

  const options = await generateRegistrationOptions({
    rpName: RP_NAME,
    rpID: RP_ID,
    userName: username,
    userDisplayName: username,
    // 同じ authenticator の二重登録を防ぐ
    excludeCredentials: userCredentials.map((cred) => ({
      id: cred.id,
      type: "public-key",
    })),
    authenticatorSelection: {
      // "platform" = 組み込み (Touch ID); "cross-platform" = セキュリティキー
      authenticatorAttachment: "platform",
      residentKey: "preferred",
      userVerification: "preferred",
    },
  });

  // 検証用のチャレンジを保存
  challenges.set(userId, options.challenge);

  res.json(options);
});

サーバー: 登録の検証

app.post("/auth/register/finish", async (req, res) => {
  const { userId, credential } = req.body;
  const expectedChallenge = challenges.get(userId);

  if (!expectedChallenge) {
    return res.status(400).json({ error: "No challenge found" });
  }

  let verification;
  try {
    verification = await verifyRegistrationResponse({
      response: credential,
      expectedChallenge,
      expectedOrigin: ORIGIN,
      expectedRPID: RP_ID,
    });
  } catch (err) {
    return res.status(400).json({ error: (err as Error).message });
  }

  if (!verification.verified || !verification.registrationInfo) {
    return res.status(400).json({ error: "Verification failed" });
  }

  // 認証情報を保存 (本番環境では DB に保存)
  const { credential: cred } = verification.registrationInfo;
  const userCreds = credentials.get(userId) || [];
  userCreds.push({
    id: cred.id,
    publicKey: cred.publicKey,
    counter: cred.counter,
    deviceType: verification.registrationInfo.credentialDeviceType,
    backedUp: verification.registrationInfo.credentialBackedUp,
  });
  credentials.set(userId, userCreds);
  challenges.delete(userId);

  res.json({ ok: true });
});

クライアント: パスキーの登録

// client/auth.ts
import {
  startRegistration,
  browserSupportsWebAuthn,
} from "@simplewebauthn/browser";

export async function registerPasskey(userId: string, username: string) {
  if (!browserSupportsWebAuthn()) {
    throw new Error("WebAuthn not supported in this browser");
  }

  // 1. サーバーからオプションを取得
  const optionsRes = await fetch("/auth/register/begin", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, username }),
  });
  const options = await optionsRes.json();

  // 2. ユーザーにプロンプトを表示 (Touch ID / Face ID が開きます)
  let credential;
  try {
    credential = await startRegistration({ optionsJSON: options });
  } catch (err: any) {
    if (err.name === "InvalidStateError") {
      throw new Error("This authenticator is already registered");
    }
    throw err;
  }

  // 3. サーバーで検証
  const verifyRes = await fetch("/auth/register/finish", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, credential }),
  });
  const result = await verifyRes.json();

  if (!result.ok) throw new Error(result.error);
  return result;
}

認証フロー

概要


Client                          Server
  |                                |
  |-- POST /auth/login/begin ----->|
  |                                | 1. チャレンジを生成
  |<-- { options } ---------------|
  |                                |
  | 2. navigator.credentials.get(options)
  |    (ユーザーが Touch ID をタップ)
  |                                |
  |-- POST /auth/login/finish ---->|
  |   { assertion }                | 3. 署名 + カウンターを検証

(原文はここで切り詰められています)
📜 原文 SKILL.md(Claudeが読む英語/中国語)を展開

WebAuthn / Passkeys

Overview

WebAuthn (Web Authentication) lets users authenticate with biometrics (Face ID, Touch ID, Windows Hello) or hardware keys (YubiKey) instead of passwords. Passkeys are WebAuthn credentials synced across devices via iCloud Keychain, Google Password Manager, or 1Password.

Key concepts:

  • Relying Party (RP): Your server — verifies credentials
  • Authenticator: Device/platform (Touch ID, Face ID, YubiKey)
  • Credential: Public/private key pair — private key never leaves device
  • Challenge: Server-issued random bytes — prevents replay attacks

Setup

Install @simplewebauthn

npm install @simplewebauthn/server @simplewebauthn/browser
# Types
npm install -D @types/node

SimpleWebAuthn abstracts the low-level CBOR/COSE encoding and handles most edge cases.

Configure Relying Party

// config/webauthn.ts
export const RP_NAME = "My App";
export const RP_ID = process.env.RP_ID || "localhost"; // domain, no protocol/port
export const ORIGIN = process.env.ORIGIN || "http://localhost:3000";
// RP_ID must match the domain of ORIGIN
// For production: RP_ID = "myapp.com", ORIGIN = "https://myapp.com"

Registration Flow

Overview

Client                          Server
  |                                |
  |-- POST /auth/register/begin -->|
  |                                | 1. Generate challenge
  |<-- { options } ---------------|
  |                                |
  | 2. navigator.credentials.create(options)
  |    (user taps Touch ID / Face ID)
  |                                |
  |-- POST /auth/register/finish ->|
  |   { credential }               | 3. Verify & store public key
  |<-- { ok: true } --------------|

Server: generate registration options

// routes/auth.ts
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { RP_ID, RP_NAME, ORIGIN } from "../config/webauthn";

// In-memory store for demo; use DB in production
const challenges = new Map<string, string>(); // userId → challenge
const credentials = new Map<string, any[]>(); // userId → credentials[]

app.post("/auth/register/begin", async (req, res) => {
  const { userId, username } = req.body;

  // Fetch existing credentials for the user (to exclude re-registration)
  const userCredentials = credentials.get(userId) || [];

  const options = await generateRegistrationOptions({
    rpName: RP_NAME,
    rpID: RP_ID,
    userName: username,
    userDisplayName: username,
    // Prevent registering the same authenticator twice
    excludeCredentials: userCredentials.map((cred) => ({
      id: cred.id,
      type: "public-key",
    })),
    authenticatorSelection: {
      // "platform" = built-in (Touch ID); "cross-platform" = security key
      authenticatorAttachment: "platform",
      residentKey: "preferred",
      userVerification: "preferred",
    },
  });

  // Store challenge for verification
  challenges.set(userId, options.challenge);

  res.json(options);
});

Server: verify registration

app.post("/auth/register/finish", async (req, res) => {
  const { userId, credential } = req.body;
  const expectedChallenge = challenges.get(userId);

  if (!expectedChallenge) {
    return res.status(400).json({ error: "No challenge found" });
  }

  let verification;
  try {
    verification = await verifyRegistrationResponse({
      response: credential,
      expectedChallenge,
      expectedOrigin: ORIGIN,
      expectedRPID: RP_ID,
    });
  } catch (err) {
    return res.status(400).json({ error: (err as Error).message });
  }

  if (!verification.verified || !verification.registrationInfo) {
    return res.status(400).json({ error: "Verification failed" });
  }

  // Store the credential (save to DB in production)
  const { credential: cred } = verification.registrationInfo;
  const userCreds = credentials.get(userId) || [];
  userCreds.push({
    id: cred.id,
    publicKey: cred.publicKey,
    counter: cred.counter,
    deviceType: verification.registrationInfo.credentialDeviceType,
    backedUp: verification.registrationInfo.credentialBackedUp,
  });
  credentials.set(userId, userCreds);
  challenges.delete(userId);

  res.json({ ok: true });
});

Client: register passkey

// client/auth.ts
import {
  startRegistration,
  browserSupportsWebAuthn,
} from "@simplewebauthn/browser";

export async function registerPasskey(userId: string, username: string) {
  if (!browserSupportsWebAuthn()) {
    throw new Error("WebAuthn not supported in this browser");
  }

  // 1. Get options from server
  const optionsRes = await fetch("/auth/register/begin", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, username }),
  });
  const options = await optionsRes.json();

  // 2. Prompt user (opens Touch ID / Face ID)
  let credential;
  try {
    credential = await startRegistration({ optionsJSON: options });
  } catch (err: any) {
    if (err.name === "InvalidStateError") {
      throw new Error("This authenticator is already registered");
    }
    throw err;
  }

  // 3. Verify with server
  const verifyRes = await fetch("/auth/register/finish", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, credential }),
  });
  const result = await verifyRes.json();

  if (!result.ok) throw new Error(result.error);
  return result;
}

Authentication Flow

Overview

Client                          Server
  |                                |
  |-- POST /auth/login/begin ----->|
  |                                | 1. Generate challenge
  |<-- { options } ---------------|
  |                                |
  | 2. navigator.credentials.get(options)
  |    (user taps Touch ID)
  |                                |
  |-- POST /auth/login/finish ---->|
  |   { assertion }                | 3. Verify signature + counter
  |<-- { token } -----------------|

Server: generate authentication options

import {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from "@simplewebauthn/server";

app.post("/auth/login/begin", async (req, res) => {
  const { userId } = req.body;
  const userCredentials = credentials.get(userId) || [];

  if (userCredentials.length === 0) {
    return res.status(400).json({ error: "No passkeys registered" });
  }

  const options = await generateAuthenticationOptions({
    rpID: RP_ID,
    allowCredentials: userCredentials.map((cred) => ({
      id: cred.id,
      type: "public-key",
    })),
    userVerification: "preferred",
  });

  challenges.set(userId, options.challenge);
  res.json(options);
});

Server: verify authentication

app.post("/auth/login/finish", async (req, res) => {
  const { userId, assertion } = req.body;
  const expectedChallenge = challenges.get(userId);
  const userCredentials = credentials.get(userId) || [];

  const credential = userCredentials.find((c) => c.id === assertion.id);
  if (!credential) {
    return res.status(400).json({ error: "Credential not found" });
  }

  let verification;
  try {
    verification = await verifyAuthenticationResponse({
      response: assertion,
      expectedChallenge: expectedChallenge!,
      expectedOrigin: ORIGIN,
      expectedRPID: RP_ID,
      credential: {
        id: credential.id,
        publicKey: credential.publicKey,
        counter: credential.counter,
      },
    });
  } catch (err) {
    return res.status(400).json({ error: (err as Error).message });
  }

  if (!verification.verified) {
    return res.status(401).json({ error: "Authentication failed" });
  }

  // Update counter (replay attack protection)
  credential.counter = verification.authenticationInfo.newCounter;
  challenges.delete(userId);

  // Issue session/JWT here
  const token = issueJWT(userId);
  res.json({ ok: true, token });
});

Client: authenticate with passkey

import { startAuthentication } from "@simplewebauthn/browser";

export async function loginWithPasskey(userId: string) {
  // 1. Get challenge from server
  const optionsRes = await fetch("/auth/login/begin", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId }),
  });
  const options = await optionsRes.json();

  // 2. Prompt user biometric
  let assertion;
  try {
    assertion = await startAuthentication({ optionsJSON: options });
  } catch (err: any) {
    if (err.name === "NotAllowedError") {
      throw new Error("Authentication cancelled or timed out");
    }
    throw err;
  }

  // 3. Verify with server
  const verifyRes = await fetch("/auth/login/finish", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userId, assertion }),
  });
  return verifyRes.json();
}

Database Schema (Production)

-- Users table
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  username TEXT UNIQUE NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Passkeys / credentials
CREATE TABLE passkeys (
  id TEXT PRIMARY KEY,              -- credential id (base64url)
  user_id UUID REFERENCES users(id) ON DELETE CASCADE,
  public_key BYTEA NOT NULL,        -- stored as bytes
  counter BIGINT NOT NULL DEFAULT 0,
  device_type TEXT,                 -- 'platform' | 'cross-platform'
  backed_up BOOLEAN DEFAULT FALSE,  -- synced passkey?
  created_at TIMESTAMPTZ DEFAULT NOW(),
  last_used_at TIMESTAMPTZ
);

-- Challenges (short-lived, use Redis TTL in production)
CREATE TABLE challenges (
  user_id UUID PRIMARY KEY,
  challenge TEXT NOT NULL,
  expires_at TIMESTAMPTZ DEFAULT (NOW() + INTERVAL '5 minutes')
);

UI Pattern: Passkey Button

// components/PasskeyButton.tsx
import { registerPasskey, loginWithPasskey } from "@/lib/auth";
import { browserSupportsWebAuthn } from "@simplewebauthn/browser";

export function PasskeyButton({ userId, username, mode }: {
  userId: string;
  username: string;
  mode: "register" | "login";
}) {
  if (!browserSupportsWebAuthn()) return null;

  const handleClick = async () => {
    try {
      if (mode === "register") {
        await registerPasskey(userId, username);
        alert("Passkey registered!");
      } else {
        const { token } = await loginWithPasskey(userId);
        localStorage.setItem("token", token);
      }
    } catch (err: any) {
      alert(err.message);
    }
  };

  return (
    <button
      onClick={handleClick}
      className="flex items-center gap-2 rounded-lg border px-4 py-2"
    >
      🔑 {mode === "register" ? "Register Passkey" : "Sign in with Passkey"}
    </button>
  );
}

Other Languages

Python (py_webauthn)

pip install py_webauthn
import webauthn

# Registration
options = webauthn.generate_registration_options(
    rp_id="myapp.com",
    rp_name="My App",
    user_name="alice@example.com",
)
# Verification
verification = webauthn.verify_registration_response(
    credential=response,
    expected_challenge=challenge,
    expected_rp_id="myapp.com",
    expected_origin="https://myapp.com",
)

Java (webauthn4j)

<dependency>
  <groupId>com.webauthn4j</groupId>
  <artifactId>webauthn4j-core</artifactId>
  <version>0.22.0</version>
</dependency>

Security Checklist

  • [ ] HTTPS required — WebAuthn only works on secure origins (or localhost)
  • [ ] RP_ID matches domain — must equal the effective domain, not a subdomain or port
  • [ ] Store counter — increment on each auth; reject if counter doesn't increase (replay protection)
  • [ ] Short-lived challenges — expire after 5 minutes; delete after use
  • [ ] User verification — use "preferred" or "required" (not "discouraged")
  • [ ] Exclude existing credentials — pass excludeCredentials during registration
  • [ ] Backup public key — you can't recover a passkey credential if DB is lost

Troubleshooting

Error Cause Fix
InvalidStateError Credential already registered Pass excludeCredentials in options
NotAllowedError User cancelled or no matching credential Show friendly error message
SecurityError RP_ID doesn't match origin Set RP_ID to effective domain
AbortError Operation timed out Implement retry UI
Counter mismatch Possible cloned authenticator Log and investigate; block if needed