Passkey WebAuthn register & login
Passkey (WebAuthn) end-to-end: registration challenge, attestation verification, login dengan Face ID / Touch ID. Tanpa password sama sekali.
Dipublikasikan 8 Juni 2026
Password mati segan, hidup tak mau. Passkey adalah masa depan — user pakai Face ID atau Touch ID, dan login langsung. Backend sebenarnya gak nyimpen rahasia apa-apa, cuma public key. Tapi proses-nya rumit karena WebAuthn full spec. Snippet ini pakai @simplewebauthn library yang abstract bagian crypto.
Kode
// server.ts — Express server
import express from "express";
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";
import type {
RegistrationResponseJSON,
AuthenticationResponseJSON,
} from "@simplewebauthn/types";
const rpName = "Kodekarawaci";
const rpID = "kodekarawaci.id";
const origin = `https://${rpID}`;
// In-memory store — ganti DB
interface User {
id: string;
email: string;
credentials: Array<{
credentialID: Uint8Array;
credentialPublicKey: Uint8Array;
counter: number;
}>;
}
const users = new Map<string, User>();
const challenges = new Map<string, string>(); // userId → challenge
const app = express();
app.use(express.json());
// REGISTRATION: step 1 — server kasih challenge
app.post("/api/passkey/register-begin", async (req, res) => {
const { email } = req.body as { email: string };
let user = [...users.values()].find((u) => u.email === email);
if (!user) {
user = { id: crypto.randomUUID(), email, credentials: [] };
users.set(user.id, user);
}
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: new TextEncoder().encode(user.id),
userName: user.email,
attestationType: "none",
excludeCredentials: user.credentials.map((c) => ({
id: c.credentialID,
transports: ["internal", "hybrid"],
})),
authenticatorSelection: {
residentKey: "required",
userVerification: "preferred",
},
});
challenges.set(user.id, options.challenge);
res.json({ ...options, userId: user.id });
});
// REGISTRATION: step 2 — verify response browser
app.post("/api/passkey/register-finish", async (req, res) => {
const { userId, attResp } = req.body as {
userId: string;
attResp: RegistrationResponseJSON;
};
const user = users.get(userId);
const expectedChallenge = challenges.get(userId);
if (!user || !expectedChallenge) {
return res.status(400).json({ error: "Session invalid" });
}
const verification = await verifyRegistrationResponse({
response: attResp,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
if (!verification.verified || !verification.registrationInfo) {
return res.status(400).json({ error: "Verification failed" });
}
const { credentialID, credentialPublicKey, counter } =
verification.registrationInfo;
user.credentials.push({ credentialID, credentialPublicKey, counter });
challenges.delete(userId);
res.json({ ok: true });
});
// LOGIN: step 1 — kasih challenge
app.post("/api/passkey/login-begin", async (req, res) => {
const options = await generateAuthenticationOptions({
rpID,
userVerification: "preferred",
});
challenges.set("__login__", options.challenge);
res.json(options);
});
// LOGIN: step 2 — verify assertion
app.post("/api/passkey/login-finish", async (req, res) => {
const { authResp } = req.body as { authResp: AuthenticationResponseJSON };
const expectedChallenge = challenges.get("__login__");
if (!expectedChallenge) {
return res.status(400).json({ error: "Session invalid" });
}
// Cari user berdasarkan credentialID
const credIdBuffer = Buffer.from(authResp.id, "base64url");
let foundUser: User | undefined;
let foundCred: User["credentials"][number] | undefined;
for (const user of users.values()) {
const cred = user.credentials.find((c) =>
Buffer.from(c.credentialID).equals(credIdBuffer)
);
if (cred) {
foundUser = user;
foundCred = cred;
break;
}
}
if (!foundUser || !foundCred) {
return res.status(400).json({ error: "Credential tidak dikenali" });
}
const verification = await verifyAuthenticationResponse({
response: authResp,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
credentialID: foundCred.credentialID,
credentialPublicKey: foundCred.credentialPublicKey,
counter: foundCred.counter,
},
});
if (!verification.verified) {
return res.status(400).json({ error: "Verification failed" });
}
foundCred.counter = verification.authenticationInfo.newCounter;
res.json({ ok: true, userId: foundUser.id, email: foundUser.email });
});
Pemakaian
// Client-side — registration
import { startRegistration, startAuthentication } from "@simplewebauthn/browser";
async function daftarPasskey(email: string) {
const r1 = await fetch("/api/passkey/register-begin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
const options = await r1.json();
// Browser prompt Face ID / Touch ID / Windows Hello
const attResp = await startRegistration(options);
await fetch("/api/passkey/register-finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: options.userId, attResp }),
});
}
// Client-side — login
async function loginPasskey() {
const r1 = await fetch("/api/passkey/login-begin", { method: "POST" });
const options = await r1.json();
const authResp = await startAuthentication(options);
const r2 = await fetch("/api/passkey/login-finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ authResp }),
});
return r2.json();
}
Kapan dipakai
- Banking / fintech Indonesia (BCA, BRI) — passkey lebih kuat dari OTP SMS yang bisa di-SIM swap.
- Marketplace dengan transaksi tinggi — kurangi friction login tanpa kurangi security.
- B2B SaaS — passkey + SSO untuk enterprise customer.
- Healthcare (BPJS, RS) — compliance + UX user yang gak melek tech.
Catatan
- rpID harus match domain — passkey terkait dengan domain. Subdomain bisa share kalau rpID di-set ke parent. Beda domain = passkey berbeda.
- userVerification: “preferred” — biar Touch ID / PIN diminta tapi tidak block kalau device gak support.
"required"lebih strict tapi exclude beberapa hardware key. - Counter check — hardware authenticator increment counter setiap auth. Kalau counter mundur, possible cloning attack. Snippet ini track per credential.
- Backup auth method — selalu sediakan fallback (magic link, OTP). User bisa lost device.
- Cross-device flow — bisa register di Mac, login di iPhone karena iCloud sync. UX-nya QR code yang scan dari device kedua.
WebAuthn UX di Safari iOS < 17 buggy — kadang prompt tidak muncul. Kalau target audience Indonesia banyak yang masih iOS lama, tunjukkan tutorial fallback.
# tags
passkeywebauthnpasswordlessauthbiometric
Ditulis oleh Asti Larasati · 8 Juni 2026