karawaci.kode

← Semua snippet

TypeScript Lanjut Auth

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