Saat mengumpulkan alamat email selama pendaftaran, login, checkout, atau pemulihan akun, verifikasi kepemilikan biasanya memerlukan pengiriman kode sandi sekali pakai (OTP) atau magic link out-of-band. Hal ini memaksa pengguna untuk meninggalkan halaman Anda, menunggu pengiriman email, dan menyalin kode—sehingga menimbulkan hambatan, pengabaian, dan risiko phishing, sekaligus mengungkapkan kepada penyedia email pihak tepercaya (RP) mana yang dikunjungi pengguna.
Email Verification API (ekstensi HTML sisi klien) dan Email Verification Protocol (EVP) (protokol kriptografis backend) memungkinkan pihak tepercaya memverifikasi bahwa pengguna mengontrol alamat email tanpa mengirim email verifikasi. Browser menjadi perantara antara pihak tepercaya dan penyedia email pengguna (penerbit):
- Pihak tepercaya menyertakan
<input>tersembunyi denganautocomplete="email-verification-token"dannoncekriptografis sekali pakai di<form>yang sama dengan input email. - Saat pengguna memilih atau memasukkan alamat email, browser akan mencari data DNS
_email-verification.<domain>TXTdomain, memverifikasi bahwa pengguna memiliki sesi aktif dengan penerbit yang berwenang, dan meminta Token Verifikasi Email (EVT) tanpa mengungkapkan identitas pihak tepercaya kepada penerbit. - Browser mengikat EVT ke origin dan sesi
noncepihak tepercaya dalam Key Binding JWT (KB-JWT) yang ditandatangani dengan pasangan kunci browser sementara (EVT + KB-JWT), mengisi input tersembunyi, dan mengirimkan formulir. - Backend pihak tepercaya memverifikasi rantai kriptografis dan, jika valid, melewati langkah OTP email atau magic link.
1. Mengaktifkan Uji Coba Origin dan Merender Formulir Verifikasi
Untuk berpartisipasi sebagai situs verifikasi (pihak tepercaya):
- Berikan token Uji Coba Origin di halaman yang menghosting formulir menggunakan header respons HTTP
Origin-Trialatau elemen<meta http-equiv="origin-trial">.- Uji coba origin pihak ketiga: SDK identitas tersemat dapat memasukkan token uji coba pihak ketiga, asalkan origin pendaftar Uji Coba Origin cocok dengan domain penerbit (situs yang sama dengan penerbit, seperti
https://issuer.example).
- Uji coba origin pihak ketiga: SDK identitas tersemat dapat memasukkan token uji coba pihak ketiga, asalkan origin pendaftar Uji Coba Origin cocok dengan domain penerbit (situs yang sama dengan penerbit, seperti
- Buat nonce acak secara kriptografis per render formulir dengan entropi minimal 128 bit (misalnya,
randomBytes(32).toString('base64url')darinode:crypto), simpan di status sesi sisi server yang diberi kunci oleh cookie ID sesiHttpOnly,Secure,SameSite=Lax(jangan pernah menyimpan nilainonceyang diharapkan langsung di dalam cookie klien tempat penyerang dapat memutar ulang cookie dan token), dan render ke dalam atributnonceinput token tersembunyi. Sajikan halaman denganCache-Control: no-storeagar navigasi kembali/maju tidak menggunakan kembali nonce yang sudah digunakan. - Tambahkan input token tersembunyi (
autocomplete="email-verification-token") di dalam<form>yang sama dengan input email (autocomplete="email").
<!-- Serve with:
Origin-Trial: <YOUR_ORIGIN_TRIAL_TOKEN>
Cache-Control: no-store -->
<form method="POST" action="/api/verify-email">
<label for="email">Email address:</label>
<input
type="email"
id="email"
name="email"
autocomplete="email"
required
/>
<!-- MANDATORY: Must be in the same <form> as the email input.
The nonce value must be freshly generated on the server per form render. -->
<input
type="hidden"
name="token"
nonce="SERVER_GENERATED_PER_FORM_NONCE"
autocomplete="email-verification-token"
/>
<button type="submit">Continue</button>
</form>
2. Memverifikasi Token yang Dikirim di Server
Saat formulir dikirim, perlakukan kolom token sebagai input yang tidak tepercaya. Baca dan hapus nonce yang diharapkan secara atomik dari penyimpanan sesi sisi server Anda setelah diterima. Jika kolom token kosong (misalnya, di browser yang tidak mendukung EVP atau saat pengguna logout dari penyedia emailnya) atau jika verifikasi gagal, segera kembali ke alur OTP email atau magic link standar Anda:
// Replace with your public relying-party origin
const RP_ORIGIN = 'https://rp.example.com';
export async function handleSignupSubmission(request) {
const formData = await request.formData();
const submittedEmail = String(formData.get('email') || '').trim();
const rawToken = String(formData.get('token') || '').trim();
// Atomically read and delete the single-use nonce from server-side session state
const expectedNonce = await consumeSessionNonce(request);
if (rawToken && expectedNonce) {
try {
await verifyEmailVerificationToken({
rawToken,
submittedEmail,
expectedNonce,
expectedAudience: RP_ORIGIN,
});
// Fast path: email ownership is cryptographically verified; skip OTP email.
return completeAccountCreation(submittedEmail);
} catch (err) {
// Log token verification failures before falling back to standard email verification.
console.error('Email verification token check failed:', err);
}
}
// Fallback path: send standard OTP code or magic link email to submittedEmail.
return sendTraditionalVerificationEmail(submittedEmail);
}
Selalu gunakan library SD-JWT dan JOSE/JWT yang sudah ada untuk platform backend Anda (seperti @sd-jwt/core dan jose di Node.js, sd-jwt-python / jwcrypto di Python, atau sd-jwt-java / Nimbus JOSE+JWT di JVM) untuk mengimplementasikan verifyEmailVerificationToken, bukan membuat kode penguraian JWT atau SD-JWT kustom secara manual.
Langkah 1: Mengurai klaim SD-JWT dan memvalidasi alamat email
Setelah menggunakan expectedNonce sekali pakai dari penyimpanan sesi sisi server, dekode presentasi SD-JWT (EVT + KB-JWT) dan verifikasi bahwa:
- JWT penerbit (
jwt) dan JWT Key Binding (kbJwt) ada, bersama dengan klaim penerbit (iss). evtPayload.email_verified === true.evtPayload.emailmencocokkan email formulir yang dikirim menggunakan perbandingan yang tidak peka huruf besar/kecil (toLowerCase()).
import { createHash } from 'node:crypto';
import { decodeSdJwtSync } from '@sd-jwt/core';
const hasher = (data, alg) =>
createHash(alg === 'sha-256' ? 'sha256' : alg).update(data).digest();
const decoded = decodeSdJwtSync(rawToken, hasher);
const evtPayload = decoded.jwt.payload;
const iss = evtPayload.iss;
if (!expectedNonce || !decoded.kbJwt || !iss || evtPayload.email_verified !== true) {
throw new Error('Missing nonce, KB-JWT, issuer, or email_verified !== true.');
}
// MANDATORY: Compare email addresses case-insensitively
if (evtPayload.email?.toLowerCase() !== submittedEmail.toLowerCase()) {
throw new Error('Submitted email does not match the EVT email claim.');
}
Langkah 2: Validasi delegasi TXT DNS dan muat JWKS penerbit
Sebelum membuat permintaan jaringan apa pun ke iss, pastikan domain email yang dikirimkan mendelegasikan otoritas ke penerbit yang sama melalui data TXT _email-verification.<domain> DNS (iss=<issuer-host> -> https://<issuer-host> tanpa port, jalur, atau garis miring di akhir). Setelah delegasi DNS cocok dengan iss byte-for-byte, ambil ${iss}/.well-known/email-verification melalui HTTPS, konfirmasi bahwa metadata.issuer === iss, dan muat JSON Web Key Set (JWKS) penerbit.
Catatan tentang penanganan
kid: Beberapa penyedia email utama (seperti Gmail) menghilangkankiddari header EVT. Jikakidtidak ada dan JWKS berisi beberapa kunci,joseakan menampilkanERR_JWKS_MULTIPLE_MATCHING_KEYS(AsyncIterabledariCryptoKeyyang cocok)—lakukan iterasifor await (const publicKey of err)untuk memverifikasi setiap kunci, seperti yang ditunjukkan pada Langkah 3.
import dns from 'node:dns/promises';
import { createRemoteJWKSet } from 'jose';
const domain = submittedEmail.split('@')[1];
const txtRecords = (await dns.resolveTxt(`_email-verification.${domain}`)).map((r) => r.join(''));
if (!txtRecords.some((txt) => txt.startsWith('iss=') && `https://${txt.slice(4).trim()}` === iss)) {
throw new Error(`Issuer "${iss}" is not delegated via DNS for "${domain}".`);
}
const discoveryUrl = new URL('/.well-known/email-verification', iss);
if (discoveryUrl.protocol !== 'https:') throw new Error('Issuer must use HTTPS.');
const metadata = await (await fetch(discoveryUrl)).json();
if (metadata.issuer !== iss || !metadata.jwks_uri?.startsWith('https://')) {
throw new Error('Invalid issuer metadata or non-HTTPS jwks_uri.');
}
const issuerJwks = createRemoteJWKSet(new URL(metadata.jwks_uri));
Langkah 3: Verifikasi tanda tangan EVT dan JWT Pengikatan Kunci (KB-JWT)
Terakhir, verifikasi kedua tanda tangan dan binding kriptografis antara kedua token menggunakan daftar yang diizinkan algoritma asimetris eksplisit (['Ed25519', 'EdDSA', 'ES256']):
- Tanda tangan EVT (
verifier): Verifikasi terhadapissuerJwks(menangkapERR_JWKS_MULTIPLE_MATCHING_KEYSuntuk melakukan iterasi kunci kandidat saatkiddihilangkan), menerapkanissdan periode keaktualan yang ketat (maxTokenAge: '5m',clockTolerance: '1m'). - Binding pemegang (opsi
kbVerifierdansdJwt.verify): Ekstrak kunci publik sementara browser darievtPayload.cnf.jwk, konfirmasi bahwacnf.jwk.algdandecoded.kbJwt.header.algada diALLOWED_ALGSdan cocok (memperlakukan'Ed25519'dan'EdDSA'sebagai setara saat penerapan Uji Coba Origin bertransisi ke RFC 9864'Ed25519'), dan verifikasi tanda tanganKB-JWTdengantyp: 'kb+jwt'. TeruskankeyBindingNonce: expectedNonce,expectedKeyBindingAudience: expectedAudience, dankeyBindingMaxAgeSeconds: 300kesdJwt.verify()—@sd-jwt/corehanya memanggilkbVerifierdan memvalidasinonce,aud,iat, dansd_hashsaatkeyBindingNoncediberikan.
import { SDJwtInstance } from '@sd-jwt/core';
import { importJWK, jwtVerify } from 'jose';
// IETF draft-hardt-email-verification-02 (§3.3, §5.1.1) specifies RFC 9864 'Ed25519' and 'ES256'.
// TODO: Remove 'EdDSA' once live issuers (e.g. Gmail) and Chrome Origin Trial builds finish
// transitioning from 'EdDSA' (with crv: 'Ed25519') to 'Ed25519'.
const ALLOWED_ALGS = ['Ed25519', 'EdDSA', 'ES256'];
const normalizeAlg = (alg) => (alg === 'Ed25519' ? 'EdDSA' : alg);
const evtOptions = {
typ: 'ev+sd-jwt',
issuer: iss,
algorithms: ALLOWED_ALGS,
maxTokenAge: '5m',
clockTolerance: '1m',
};
const sdJwt = new SDJwtInstance({
hasher,
verifier: async (data, sig) => {
const compactEvt = `${data}.${sig}`;
try {
await jwtVerify(compactEvt, issuerJwks, evtOptions);
} catch (err) {
// When `kid` is omitted (e.g. Gmail) and multiple keys match in the JWKS,
// jose throws JWKSMultipleMatchingKeys, which yields each matching CryptoKey.
if (err?.code !== 'ERR_JWKS_MULTIPLE_MATCHING_KEYS') throw err;
let matched = false;
for await (const publicKey of err) {
try {
await jwtVerify(compactEvt, publicKey, evtOptions);
matched = true;
break;
} catch {}
}
if (!matched) throw err;
}
return true;
},
kbVerifier: async (data, sig) => {
const holderJwk = evtPayload.cnf?.jwk;
const holderAlg = holderJwk?.alg;
const kbAlg = decoded.kbJwt.header.alg;
if (
!holderAlg ||
!kbAlg ||
!ALLOWED_ALGS.includes(holderAlg) ||
!ALLOWED_ALGS.includes(kbAlg) ||
normalizeAlg(kbAlg) !== normalizeAlg(holderAlg)
) {
throw new Error('Missing, disallowed, or mismatched KB-JWT algorithm.');
}
const holderKey = await importJWK(holderJwk, kbAlg);
await jwtVerify(`${data}.${sig}`, holderKey, {
typ: 'kb+jwt',
audience: expectedAudience,
algorithms: [kbAlg],
maxTokenAge: '5m',
clockTolerance: '1m',
});
return true;
},
});
const verified = await sdJwt.verify(rawToken, {
requiredClaimKeys: ['email', 'email_verified'],
keyBindingNonce: expectedNonce,
expectedKeyBindingAudience: expectedAudience,
keyBindingMaxAgeSeconds: 300,
skewSeconds: 60,
});
Praktik terbaik
- LAKUKAN semua verifikasi token secara ketat di server. JANGAN memvalidasi token di JavaScript sisi klien atau memercayai status verifikasi sisi klien apa pun—pemeriksaan sisi klien dapat dengan mudah diabaikan atau dipalsukan dan tidak memberikan jaminan kepercayaan kepada pihak tepercaya.
- GUNAKAN library SD-JWT dan JOSE/JWT yang sudah ada untuk platform backend Anda (misalnya,
@sd-jwt/coredanjosedi Node.js) guna mengurai dan memvalidasi presentasiEVT~KB-JWT, serta meneruskankeyBindingNoncekesdJwt.verify()agar verifikasi Key Binding tidak dapat dilewati. JANGAN membuat rutinitas penguraian dan verifikasi tanda tangan JWT, SD-JWT, atausd_hashkustom secara manual. - LAKUKAN pembuatan
noncekriptografis baru (minimal 128 bit, sepertirandomBytes(32).toString('base64url')) di server untuk setiap render formulir, simpan di status sesi sisi server (bukan langsung di cookie klien), dan tayangkan halaman formulir denganCache-Control: no-store. - LAKUKAN pembacaan dan penghapusan
nonceyang diharapkan secara atomik dari penyimpanan sesi sisi server segera setelah permintaanPOSTtiba sehingga token yang disadap tidak dapat diputar ulang. - LAKUKAN perbandingan yang tidak peka huruf besar/kecil antara
evtPayload.emaildan alamat email formulir yang dikirimkan (email.toLowerCase()), sambil mempertahankan string email yang dikirimkan. - LAKUKAN verifikasi uji coba semua kunci di JWKS penerbit jika
kidtidak ada di header EVT (penyedia seperti Gmail menghilangkankid). - LAKUKAN verifikasi delegasi DNS (
_email-verification.<domain>) sebelum memercayai klaimissapa pun dalam EVT, dan konfirmasimetadata.issuer === issdalam respons.well-known/email-verification. - JANGAN menganggap verifikasi kepemilikan email sebagai bukti kemampuan pengiriman kotak masuk. EVP membuktikan bahwa pengguna mengontrol akun email dengan penyedia yang berwenang; tetap kirim email sambutan transaksional dan tangani email yang tidak terkirim seperti biasa.
- JANGAN memblokir pembuatan akun atau login saat
tokenkosong atau saat verifikasi gagal—selalu turunkan secara halus ke alur OTP email atau magic link standar Anda.
Strategi penggantian
Verifikasi Email (autocomplete="email-verification-token") dirancang sebagai progressive enhancement dibandingkan verifikasi email out-of-band tradisional (kode OTP atau magic link):
- Browser yang tidak didukung: Browser yang tidak mendukung
autocomplete="email-verification-token"akan mengabaikan input tersembunyi dan mengirimkan formulir dengan nilaitokenkosong. - Domain email yang tidak diautentikasi atau tidak berpartisipasi: Jika pengguna memasukkan alamat email yang domainnya tidak memublikasikan data
TXTDNS_email-verification, atau jika pengguna tidak memiliki sesi aktif dengan penyedia emailnya di browser, browser akan mengirimkan formulir dengantokenkosong. - Token yang sudah tidak berlaku atau tidak valid: Jika
noncesudah tidak berlaku, sudah digunakan, atau gagal dalam langkahverifyEmailVerificationTokenapa pun dihandleSignupSubmission, server akan menangkap error dan mengirim OTP tradisional atau email magic link tanpa mengganggu alur pendaftaran atau login pengguna.