Tutorial Implementasi WebAuthn Passkey pada Node.js, Express JS, dan PostgreSQL
Poin Kunci Artikel Ini:
- Mengapa Passkey WebAuthn Menggantikan PasswordPassword tradisional celah keamanan terbesar aplikasi modern.
- Serangan phishing, credential stuffing, brute force, dan kebocoran basis data terus mengancam sistem berbasis teks rahasia.
- Pengguna sering pakai ulang password sama di banyak platform.Passkey berbasis standar W3C WebAuthn (Web Authentication) dan FIDO2 selesaikan masalah ini.
Mengapa Passkey WebAuthn Menggantikan Password
Password tradisional celah keamanan terbesar aplikasi modern. Serangan phishing, credential stuffing, brute force, dan kebocoran basis data terus mengancam sistem berbasis teks rahasia. Pengguna sering pakai ulang password sama di banyak platform.
Passkey berbasis standar W3C WebAuthn (Web Authentication) dan FIDO2 selesaikan masalah ini. Mekanisme ganti kata sandi dengan kriptografi kunci publik (asymmetric cryptography). Saat registrasi, perangkat pengguna (authenticator seperti Touch ID, Face ID, Windows Hello, YubiKey, atau 1Password) buat sepasang kunci: Private Key dan Public Key.
Private key tersimpan aman dalam chip kerasenkripsi lokal (Secure Enclave, Apple T2, atau TPM) dan tidak pernah keluar dari perangkat. Public key dikirim ke server backend untuk disimpan di database. Proses autentikasi pakai mekanisme challenge-response cryptographic signature. Browser otomatis verifikasi domain asal (origin check) sebelum penandatanganan data. Serangan phishing dan man-in-the-middle otomatis gagal.
Arsitektur Kriptografi WebAuthn
Spesifikasi WebAuthn libatkan tiga komponen utama:
- Client / User Agent: Browser web (Chrome, Safari, Firefox, Edge) tempat API
navigator.credentialsdieksekusi. - Authenticator: Perangkat keras pembentuk kunci dan pemroses verifikasi biometrik atau PIN.
- Relying Party (RP): Server backend Node.js yang menerbitkan challenge dan memverifikasi tanda tangan digital.
WebAuthn gunakan format data binary COSE (CBOR Object Signing and Encryption) untuk representasi public key dan data struktur CBOR (Concise Binary Object Representation). Library @simplewebauthn/server mengabstraksi pemrosesan buffer binary, decoding CBOR, dan pembuktian algoritma kriptografi ASN.1/DER (seperti ES256, RS256, EdDSA).
Persiapan Project Node.js dan Instalasi Dependency
Inisialisasi project Node.js baru dan instal package backend yang dibutuhkan:
mkdir node-webauthn-passkey
cd node-webauthn-passkey
npm init -y
npm install express @simplewebauthn/server pg dotenv express-session cors
npm install --save-dev nodemonKonfigurasi Environment Variable
Buat file .env untuk identitas Relying Party (RP) dan koneksi database:
PORT=3000
RP_ID=localhost
RP_NAME="Aplikasi Express Passkey"
ORIGIN=http://localhost:3000
SESSION_SECRET=c8f9b1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2
DATABASE_URL=postgres://postgres:postgrespassword@localhost:5432/webauthn_dbSkema Data PostgreSQL untuk Passkey
Server wajib simpan credential ID, public key, dan tanda penghitung (counter). Buat skema database PostgreSQL berikut:
CREATE TABLE users (
id VARCHAR(255) PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
display_name VARCHAR(100) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE passkey_credentials (
id VARCHAR(255) PRIMARY KEY,
user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
public_key TEXT NOT NULL,
counter BIGINT NOT NULL DEFAULT 0,
device_type VARCHAR(32) NOT NULL,
backed_up BOOLEAN NOT NULL DEFAULT FALSE,
transports TEXT[],
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_passkey_user_id ON passkey_credentials(user_id);Fungsi kolom utama:
- id: Credential ID unik format string Base64URL dari authenticator.
- public_key: Binary Public Key dikonversi ke string Base64URL.
- counter: Angka urut transaksi autentikasi untuk deteksi duplikasi autentikator (replay attack detection).
- transports: Array string metode koneksi hardware, seperti
internal(biometrik platform),usb,nfc, atauble.
Implementasi Flow Registrasi Passkey (Attestation)
Registrasi Passkey butuh dua langkah HTTP request: pembuatan challenge dan validasi bukti registrasi.
Endpoint 1: Generate Registration Options
// server.js
const express = require('express');
const session = require('express-session');
const {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} = require('@simplewebauthn/server');
const { Pool } = require('pg');
require('dotenv').config();
const app = express();
app.use(express.json());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: true,
cookie: { secure: false, httpOnly: true, sameSite: 'lax' }
}));
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const RP_ID = process.env.RP_ID;
const ORIGIN = process.env.ORIGIN;
app.post('/api/passkey/register-challenge', async (req, res) => {
try {
const { username, displayName } = req.body;
if (!username) {
return res.status(400).json({ error: 'Username wajib diisi' });
}
let userResult = await db.query('SELECT * FROM users WHERE username = $1', [username]);
let user = userResult.rows[0];
if (!user) {
const userId = Buffer.from(Date.now().toString() + Math.random().toString()).toString('base64url');
const insertUser = await db.query(
'INSERT INTO users (id, username, display_name) VALUES ($1, $2, $3) RETURNING *',
[userId, username, displayName || username]
);
user = insertUser.rows[0];
}
const userPasskeys = await db.query(
'SELECT id, transports FROM passkey_credentials WHERE user_id = $1',
[user.id]
);
const options = await generateRegistrationOptions({
rpName: process.env.RP_NAME,
rpID: RP_ID,
userID: Buffer.from(user.id),
userName: user.username,
userDisplayName: user.display_name,
attestationType: 'none',
excludeCredentials: userPasskeys.rows.map(passkey => ({
id: passkey.id,
transports: passkey.transports || [],
})),
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred',
authenticatorAttachment: 'platform',
},
});
req.session.currentChallenge = options.challenge;
req.session.userId = user.id;
return res.json(options);
} catch (error) {
console.error('Register Challenge Error:', error);
return res.status(500).json({ error: 'Gagal membuat opsi registrasi' });
}
});Endpoint 2: Verify Registration Response
app.post('/api/passkey/register-verify', async (req, res) => {
try {
const { body } = req;
const expectedChallenge = req.session.currentChallenge;
const userId = req.session.userId;
if (!expectedChallenge || !userId) {
return res.status(400).json({ error: 'Session challenge kadaluwarsa' });
}
const verification = await verifyRegistrationResponse({
response: body,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
});
const { verified, registrationInfo } = verification;
if (verified && registrationInfo) {
const {
credentialID,
credentialPublicKey,
counter,
credentialDeviceType,
credentialBackedUp
} = registrationInfo;
await db.query(
`INSERT INTO passkey_credentials
(id, user_id, public_key, counter, device_type, backed_up, transports)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
credentialID,
userId,
Buffer.from(credentialPublicKey).toString('base64url'),
counter,
credentialDeviceType,
credentialBackedUp,
body.response.transports || []
]
);
req.session.currentChallenge = null;
return res.json({ status: 'ok', verified: true });
}
return res.status(400).json({ error: 'Verifikasi registrasi gagal' });
} catch (error) {
console.error('Register Verify Error:', error);
return res.status(400).json({ error: error.message });
}
});Implementasi Flow Autentikasi Passkey (Assertion)
Proses login memverifikasi bahwa klien memiliki private key yang cocok dengan public key di server.
Endpoint 1: Generate Authentication Options
app.post('/api/passkey/login-challenge', async (req, res) => {
try {
const { username } = req.body;
let userPasskeys = [];
if (username) {
const userResult = await db.query('SELECT id FROM users WHERE username = $1', [username]);
if (userResult.rows.length > 0) {
const userId = userResult.rows[0].id;
const passkeyQuery = await db.query(
'SELECT id, transports FROM passkey_credentials WHERE user_id = $1',
[userId]
);
userPasskeys = passkeyQuery.rows;
}
}
const options = await generateAuthenticationOptions({
rpID: RP_ID,
allowCredentials: userPasskeys.map(passkey => ({
id: passkey.id,
transports: passkey.transports || [],
})),
userVerification: 'preferred',
});
req.session.currentChallenge = options.challenge;
return res.json(options);
} catch (error) {
console.error('Login Challenge Error:', error);
return res.status(500).json({ error: 'Gagal membuat opsi autentikasi' });
}
});Endpoint 2: Verify Authentication Response
app.post('/api/passkey/login-verify', async (req, res) => {
try {
const { body } = req;
const expectedChallenge = req.session.currentChallenge;
if (!expectedChallenge) {
return res.status(400).json({ error: 'Session challenge kadaluwarsa' });
}
const credResult = await db.query('SELECT * FROM passkey_credentials WHERE id = $1', [body.id]);
const dbAuthenticator = credResult.rows[0];
if (!dbAuthenticator) {
return res.status(404).json({ error: 'Passkey tidak ditemukan di server' });
}
const verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
authenticator: {
credentialID: dbAuthenticator.id,
credentialPublicKey: Buffer.from(dbAuthenticator.public_key, 'base64url'),
counter: parseInt(dbAuthenticator.counter, 10),
transports: dbAuthenticator.transports,
},
});
const { verified, authenticationInfo } = verification;
if (verified) {
await db.query(
'UPDATE passkey_credentials SET counter = $1, last_used_at = CURRENT_TIMESTAMP WHERE id = $2',
[authenticationInfo.newCounter, dbAuthenticator.id]
);
req.session.currentChallenge = null;
req.session.loggedInUserId = dbAuthenticator.user_id;
return res.json({
status: 'ok',
verified: true,
userId: dbAuthenticator.user_id
});
}
return res.status(400).json({ error: 'Autentikasi gagal' });
} catch (error) {
console.error('Login Verify Error:', error);
return res.status(400).json({ error: error.message });
}
});
app.listen(3000, () => console.log('Server running on http://localhost:3000'));Integrasi Client-Side Browser JavaScript
Gunakan SDK @simplewebauthn/browser di frontend untuk memanggil prompt biometrik bawaan sistem operasi.
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title>Demo Passkey WebAuthn</title>
</head>
<body>
<h1>Autentikasi Passkey Node.js</h1>
<input type="text" id="username" placeholder="Masukkan Username" autocomplete="username webauthn" />
<button id="btn-register">Daftar Passkey</button>
<button id="btn-login">Login Passkey</button>
<script type="module">
import { startRegistration, startAuthentication } from 'https://unpkg.com/@simplewebauthn/browser@9.0.0/dist/bundle/index.es5.umd.min.js';
document.getElementById('btn-register').addEventListener('click', async () => {
const username = document.getElementById('username').value;
if (!username) return alert('Username wajib diisi');
const resOptions = await fetch('/api/passkey/register-challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, displayName: username })
});
const options = await resOptions.json();
if (options.error) return alert(options.error);
try {
const regResponse = await startRegistration(options);
const resVerify = await fetch('/api/passkey/register-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(regResponse)
});
const result = await resVerify.json();
alert('Status Registrasi: ' + JSON.stringify(result));
} catch (err) {
alert('Registrasi Dibatalkan: ' + err.message);
}
});
document.getElementById('btn-login').addEventListener('click', async () => {
const username = document.getElementById('username').value;
const resOptions = await fetch('/api/passkey/login-challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
const options = await resOptions.json();
if (options.error) return alert(options.error);
try {
const authResponse = await startAuthentication(options);
const resVerify = await fetch('/api/passkey/login-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(authResponse)
});
const result = await resVerify.json();
alert('Status Login: ' + JSON.stringify(result));
} catch (err) {
alert('Login Dibatalkan: ' + err.message);
}
});
</script>
</body>
</html>Pengujian dan Evaluasi Keamanan Production
Pengujian WebAuthn lokal dan produksi perhatikan syarat berikut:
1. Kebijakan HTTPS dan Origin
WebAuthn hanya aktif di konteks aman (Secure Context):
- Lokal:
localhostatau127.0.0.1diperbolehkan HTTP biasa. - Produksi: Wajib gunakan sertifikat TLS/SSL (HTTPS). Parameter
RP_IDharus persis nama domain (misalapp.example.com) danORIGINharus mencakup skema (https://app.example.com).
2. Deteksi Replay Attack lewat Counter Check
Setiap transaksi autentikasi menghasilkan angka counter baru dari hardware. Jika server menerima nilai newCounter yang sama atau lebih kecil dari nilai simpanan database, tanda bahaya klon fisik authenticator. Backend harus langsung memblokir akses credential ID terkait.
3. Pengecekan Dukungan Browser (Fallback Strategy)
Periksa fitur browser sebelum render tombol UI Passkey:
if (window.PublicKeyCredential &&
await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()) {
// Tampilkan tombol Passkey
} else {
// Tampilkan tombol fallback (OTP / Magic Link)
}Implementasi WebAuthn Passkey tingkatkan keamanan backend Node.js secara signifikan, hilangkan risiko phising total, dan berikan pengalaman login instan tanpa password.


