Workersでパスキー実装
この記事で作るもの
Cloudflare Workers 上で動く、最小構成のパスキー(WebAuthn)ログインを作ります。
- ユーザー名を入力
- パスキー登録(
navigator.credentials.create) - パスキーログイン(
navigator.credentials.get) - 検証は Workers 側(
@simplewebauthn/server) - データ保存は Cloudflare D1
対象読者: JavaScript/TypeScript の基本文法は分かるが、WebAuthn は初めての人。
0. 先に知っておくこと(超ざっくり)
- パスキーは、端末の生体認証やPINを使ってログインする仕組み。
- サーバーは「公開鍵」を保存し、ログイン時の署名を検証する。
- パスワードそのものをやり取りしないので、フィッシング耐性が高い。
1. 前提ツールを入れる
1-1. Node.js の確認
node -v
- v20 以上推奨。
1-2. Wrangler の確認(なければインストール)
npm install -g wrangler
wrangler --version
1-3. Cloudflare へログイン
wrangler login
ブラウザが開くので承認します。
2. プロジェクト作成
mkdir workers-passkey-demo
cd workers-passkey-demo
npm init -y
npm install hono @simplewebauthn/server
npm install -D typescript wrangler
npx tsc --init
hono: Workers でAPIを作りやすくする@simplewebauthn/server: WebAuthn の challenge 生成・検証
3. D1 データベース作成
3-1. D1作成
npx wrangler d1 create passkey_demo_db
実行後に次のような情報が出ます(例)。
database_name = "passkey_demo_db"database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
この database_id は後で wrangler.toml に貼ります。
3-2. テーブル作成SQLを書く
schema.sql を作成します。
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS authenticators (
credential_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
public_key TEXT NOT NULL,
counter INTEGER NOT NULL,
transports TEXT,
aaguid TEXT,
backed_up INTEGER,
credential_device_type TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS registration_challenges (
user_id TEXT PRIMARY KEY,
challenge TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS authentication_challenges (
user_id TEXT PRIMARY KEY,
challenge TEXT NOT NULL,
created_at TEXT NOT NULL
);
3-3. ローカルD1に適用
npx wrangler d1 execute passkey_demo_db --local --file=./schema.sql
4. 設定ファイルを作る
4-1. wrangler.toml
name = "workers-passkey-demo"
main = "src/index.ts"
compatibility_date = "2026-01-01"
[assets]
directory = "public"
binding = "ASSETS"
[[d1_databases]]
binding = "DB"
database_name = "passkey_demo_db"
database_id = "YOUR_DATABASE_ID_HERE"
YOUR_DATABASE_ID_HEREはwrangler d1 createの結果で置換してください。
4-2. tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"types": ["@cloudflare/workers-types"]
},
"include": ["src"]
}
4-3. package.json(scriptsだけ更新)
{
"name": "workers-passkey-demo",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"dependencies": {
"@simplewebauthn/server": "^10.0.1",
"hono": "^4.6.13"
},
"devDependencies": {
"typescript": "^5.7.3",
"wrangler": "^4.39.0"
}
}
5. バックエンド(Workers API)実装
src/index.ts を作成します。
import { Hono } from 'hono';
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import type {
AuthenticatorTransportFuture,
CredentialDeviceType,
WebAuthnCredential,
} from '@simplewebauthn/server';
type Env = {
DB: D1Database;
ASSETS: Fetcher;
};
type UserRow = {
id: string;
username: string;
};
type AuthenticatorRow = {
credential_id: string;
user_id: string;
public_key: string;
counter: number;
transports: string | null;
aaguid: string | null;
backed_up: number | null;
credential_device_type: string | null;
};
const app = new Hono<{ Bindings: Env }>();
const rpName = 'Workers Passkey Demo';
function base64UrlToBytes(base64url: string): Uint8Array {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
const bin = atob(padded);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function bytesToBase64Url(bytes: Uint8Array): string {
let str = '';
for (const b of bytes) str += String.fromCharCode(b);
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
async function getOrCreateUser(env: Env, username: string): Promise<UserRow> {
const existing = await env.DB.prepare('SELECT id, username FROM users WHERE username = ?')
.bind(username)
.first<UserRow>();
if (existing) return existing;
const id = crypto.randomUUID();
await env.DB.prepare(
'INSERT INTO users (id, username, created_at) VALUES (?, ?, ?)',
)
.bind(id, username, new Date().toISOString())
.run();
return { id, username };
}
async function getAuthenticatorsByUser(env: Env, userId: string): Promise<AuthenticatorRow[]> {
const result = await env.DB.prepare(
'SELECT credential_id, user_id, public_key, counter, transports, aaguid, backed_up, credential_device_type FROM authenticators WHERE user_id = ?',
)
.bind(userId)
.all<AuthenticatorRow>();
return result.results ?? [];
}
async function getAuthenticatorByCredentialId(env: Env, credentialId: string): Promise<AuthenticatorRow | null> {
const row = await env.DB.prepare(
'SELECT credential_id, user_id, public_key, counter, transports, aaguid, backed_up, credential_device_type FROM authenticators WHERE credential_id = ?',
)
.bind(credentialId)
.first<AuthenticatorRow>();
return row ?? null;
}
app.post('/api/register/options', async (c) => {
const { username } = await c.req.json<{ username: string }>();
if (!username?.trim()) {
return c.json({ error: 'username is required' }, 400);
}
const env = c.env;
const user = await getOrCreateUser(env, username.trim());
const auths = await getAuthenticatorsByUser(env, user.id);
const origin = new URL(c.req.url).origin;
const rpID = new URL(origin).hostname;
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: user.id,
userName: user.username,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
excludeCredentials: auths.map((a) => ({
id: base64UrlToBytes(a.credential_id),
type: 'public-key',
transports: a.transports ? (JSON.parse(a.transports) as AuthenticatorTransportFuture[]) : undefined,
})),
});
await env.DB.prepare(
`INSERT INTO registration_challenges (user_id, challenge, created_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET challenge = excluded.challenge, created_at = excluded.created_at`,
)
.bind(user.id, options.challenge, new Date().toISOString())
.run();
return c.json({ userId: user.id, options });
});
app.post('/api/register/verify', async (c) => {
const { userId, response } = await c.req.json<{
userId: string;
response: Record<string, unknown>;
}>();
const env = c.env;
const challengeRow = await env.DB.prepare(
'SELECT challenge FROM registration_challenges WHERE user_id = ?',
)
.bind(userId)
.first<{ challenge: string }>();
if (!challengeRow) {
return c.json({ error: 'registration challenge not found' }, 400);
}
const origin = new URL(c.req.url).origin;
const rpID = new URL(origin).hostname;
const verification = await verifyRegistrationResponse({
response: response as Parameters<typeof verifyRegistrationResponse>[0]['response'],
expectedChallenge: challengeRow.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
if (!verification.verified || !verification.registrationInfo) {
return c.json({ verified: false }, 400);
}
const { credential } = verification.registrationInfo;
await env.DB.prepare(
`INSERT OR REPLACE INTO authenticators
(credential_id, user_id, public_key, counter, transports, aaguid, backed_up, credential_device_type, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(
bytesToBase64Url(new Uint8Array(credential.id)),
userId,
bytesToBase64Url(new Uint8Array(credential.publicKey)),
credential.counter,
JSON.stringify(credential.transports ?? []),
credential.aaguid,
credential.backedUp ? 1 : 0,
credential.credentialDeviceType,
new Date().toISOString(),
)
.run();
return c.json({ verified: true });
});
app.post('/api/auth/options', async (c) => {
const { username } = await c.req.json<{ username: string }>();
if (!username?.trim()) {
return c.json({ error: 'username is required' }, 400);
}
const env = c.env;
const user = await env.DB.prepare('SELECT id, username FROM users WHERE username = ?')
.bind(username.trim())
.first<UserRow>();
if (!user) {
return c.json({ error: 'user not found' }, 404);
}
const auths = await getAuthenticatorsByUser(env, user.id);
const options = await generateAuthenticationOptions({
userVerification: 'preferred',
allowCredentials: auths.map((a) => ({
id: base64UrlToBytes(a.credential_id),
type: 'public-key',
transports: a.transports ? (JSON.parse(a.transports) as AuthenticatorTransportFuture[]) : undefined,
})),
});
await env.DB.prepare(
`INSERT INTO authentication_challenges (user_id, challenge, created_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET challenge = excluded.challenge, created_at = excluded.created_at`,
)
.bind(user.id, options.challenge, new Date().toISOString())
.run();
return c.json({ userId: user.id, options });
});
app.post('/api/auth/verify', async (c) => {
const { userId, response } = await c.req.json<{
userId: string;
response: Record<string, unknown>;
}>();
const env = c.env;
const challengeRow = await env.DB.prepare(
'SELECT challenge FROM authentication_challenges WHERE user_id = ?',
)
.bind(userId)
.first<{ challenge: string }>();
if (!challengeRow) {
return c.json({ error: 'auth challenge not found' }, 400);
}
const credentialId = (response.id ?? '') as string;
const dbAuthenticator = await getAuthenticatorByCredentialId(env, credentialId);
if (!dbAuthenticator) {
return c.json({ error: 'authenticator not found' }, 404);
}
const origin = new URL(c.req.url).origin;
const rpID = new URL(origin).hostname;
const authenticator: WebAuthnCredential = {
id: base64UrlToBytes(dbAuthenticator.credential_id),
publicKey: base64UrlToBytes(dbAuthenticator.public_key),
counter: dbAuthenticator.counter,
transports: dbAuthenticator.transports
? (JSON.parse(dbAuthenticator.transports) as AuthenticatorTransportFuture[])
: undefined,
};
const verification = await verifyAuthenticationResponse({
response: response as Parameters<typeof verifyAuthenticationResponse>[0]['response'],
expectedChallenge: challengeRow.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: authenticator,
});
if (!verification.verified) {
return c.json({ verified: false }, 400);
}
await env.DB.prepare('UPDATE authenticators SET counter = ? WHERE credential_id = ?')
.bind(verification.authenticationInfo.newCounter, dbAuthenticator.credential_id)
.run();
return c.json({ verified: true, message: 'ログイン成功' });
});
app.get('*', (c) => c.env.ASSETS.fetch(c.req.raw));
export default app;
6. フロントエンド実装
public/index.html を作成します。
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workers Passkey Demo</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
line-height: 1.6;
}
.card {
border: 1px solid #ddd;
border-radius: 12px;
padding: 1rem;
margin-bottom: 1rem;
}
button {
border: 0;
background: #111;
color: #fff;
border-radius: 8px;
padding: 0.6rem 1rem;
cursor: pointer;
margin-right: 0.5rem;
}
input {
width: 100%;
padding: 0.6rem;
margin: 0.5rem 0 1rem;
}
pre {
background: #f6f8fa;
padding: 1rem;
border-radius: 8px;
overflow: auto;
}
</style>
</head>
<body>
<h1>Cloudflare Workers Passkey Demo</h1>
<p>ユーザー名を入れて、パスキー登録とログインを試してください。</p>
<div class="card">
<label for="username">ユーザー名</label>
<input id="username" placeholder="例: taro" />
<button id="register">1) パスキー登録</button>
<button id="login">2) パスキーログイン</button>
</div>
<div class="card">
<h2>ログ</h2>
<pre id="log">まだ実行していません。</pre>
</div>
<script>
const logEl = document.getElementById('log');
const usernameEl = document.getElementById('username');
function log(obj) {
logEl.textContent = typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2);
}
function toBase64Url(buffer) {
const bytes = new Uint8Array(buffer);
let str = '';
for (const b of bytes) str += String.fromCharCode(b);
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
function fromBase64Url(base64url) {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
const str = atob(padded);
const out = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) out[i] = str.charCodeAt(i);
return out.buffer;
}
async function register() {
const username = usernameEl.value.trim();
if (!username) {
log('ユーザー名を入力してください');
return;
}
const optionsRes = await fetch('/api/register/options', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username }),
});
const optionsJson = await optionsRes.json();
if (!optionsRes.ok) {
log(optionsJson);
return;
}
const { userId, options } = optionsJson;
options.challenge = fromBase64Url(options.challenge);
options.user.id = new TextEncoder().encode(options.user.id);
if (options.excludeCredentials) {
options.excludeCredentials = options.excludeCredentials.map((c) => ({
...c,
id: fromBase64Url(c.id),
}));
}
const credential = await navigator.credentials.create({ publicKey: options });
if (!credential) {
log('credential create に失敗しました');
return;
}
const attRes = credential.response;
const payload = {
id: credential.id,
rawId: toBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: toBase64Url(attRes.clientDataJSON),
attestationObject: toBase64Url(attRes.attestationObject),
transports: attRes.getTransports ? attRes.getTransports() : [],
},
clientExtensionResults: credential.getClientExtensionResults(),
};
const verifyRes = await fetch('/api/register/verify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ userId, response: payload }),
});
const verifyJson = await verifyRes.json();
log(verifyJson);
}
async function login() {
const username = usernameEl.value.trim();
if (!username) {
log('ユーザー名を入力してください');
return;
}
const optionsRes = await fetch('/api/auth/options', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username }),
});
const optionsJson = await optionsRes.json();
if (!optionsRes.ok) {
log(optionsJson);
return;
}
const { userId, options } = optionsJson;
options.challenge = fromBase64Url(options.challenge);
if (options.allowCredentials) {
options.allowCredentials = options.allowCredentials.map((c) => ({
...c,
id: fromBase64Url(c.id),
}));
}
const assertion = await navigator.credentials.get({ publicKey: options });
if (!assertion) {
log('credential get に失敗しました');
return;
}
const asRes = assertion.response;
const payload = {
id: assertion.id,
rawId: toBase64Url(assertion.rawId),
type: assertion.type,
response: {
clientDataJSON: toBase64Url(asRes.clientDataJSON),
authenticatorData: toBase64Url(asRes.authenticatorData),
signature: toBase64Url(asRes.signature),
userHandle: asRes.userHandle ? toBase64Url(asRes.userHandle) : null,
},
clientExtensionResults: assertion.getClientExtensionResults(),
};
const verifyRes = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ userId, response: payload }),
});
const verifyJson = await verifyRes.json();
log(verifyJson);
}
document.getElementById('register').addEventListener('click', register);
document.getElementById('login').addEventListener('click', login);
</script>
</body>
</html>
7. ローカル起動して動作確認
npm run dev
ブラウザで http://127.0.0.1:8787 を開く。
操作手順
- ユーザー名を入力(例:
taro) - 「1) パスキー登録」を押す
- OS/ブラウザのパスキーUIで登録
- 「2) パスキーログイン」を押す
{"verified": true, "message": "ログイン成功"}が出れば成功
8. 本番D1へ反映してデプロイ
8-1. 本番D1へスキーマ適用
npx wrangler d1 execute passkey_demo_db --remote --file=./schema.sql
8-2. デプロイ
npm run deploy
出力された https://xxxxx.workers.dev で同じ手順を試します。
9. 初学者がハマりやすいポイント
9-1. expectedOrigin / rpID が合わない
- ローカルと本番でURLが違う。
- この記事の実装は
new URL(c.req.url)から動的に取得しているので、環境差異に強い。
9-2. Base64URL 変換忘れ
- WebAuthnは
ArrayBufferを多用。 - JSONで送るときは Base64URL 変換が必要。
9-3. HTTPでテストしてしまう
- WebAuthnは原則 HTTPS が必要。
- ただし
localhostは例外的に許可される。
10. ここから拡張するなら
- 認証済みセッション(JWT or Cookie)を追加
- チャレンジに有効期限を設ける
- username ではなくメール+検証フローにする
- 管理画面で「登録済みデバイス一覧」「削除」を実装
まとめ
この手順どおりに進めれば、Cloudflare Workers + D1 でパスキー認証の最小実装を動かせます。
「まずは動かす」を達成したら、次はセッション管理・アカウント回復・監査ログを足して実運用レベルへ育てていくのがおすすめです。