Memverifikasi kepemilikan alamat email

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):

  1. Pihak tepercaya menyertakan <input> tersembunyi dengan autocomplete="email-verification-token" dan nonce kriptografis sekali pakai di <form> yang sama dengan input email.
  2. Saat pengguna memilih atau memasukkan alamat email, browser akan mencari data DNS _email-verification.<domain> TXT domain, memverifikasi bahwa pengguna memiliki sesi aktif dengan penerbit yang berwenang, dan meminta Token Verifikasi Email (EVT) tanpa mengungkapkan identitas pihak tepercaya kepada penerbit.
  3. Browser mengikat EVT ke origin dan sesi nonce pihak tepercaya dalam Key Binding JWT (KB-JWT) yang ditandatangani dengan pasangan kunci browser sementara (EVT + KB-JWT), mengisi input tersembunyi, dan mengirimkan formulir.
  4. 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):

  1. Berikan token Uji Coba Origin di halaman yang menghosting formulir menggunakan header respons HTTP Origin-Trial atau 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).
  2. Buat nonce acak secara kriptografis per render formulir dengan entropi minimal 128 bit (misalnya, randomBytes(32).toString('base64url') dari node:crypto), simpan di status sesi sisi server yang diberi kunci oleh cookie ID sesi HttpOnly, Secure, SameSite=Lax (jangan pernah menyimpan nilai nonce yang diharapkan langsung di dalam cookie klien tempat penyerang dapat memutar ulang cookie dan token), dan render ke dalam atribut nonce input token tersembunyi. Sajikan halaman dengan Cache-Control: no-store agar navigasi kembali/maju tidak menggunakan kembali nonce yang sudah digunakan.
  3. 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.email mencocokkan 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) menghilangkan kid dari header EVT. Jika kid tidak ada dan JWKS berisi beberapa kunci, jose akan menampilkan ERR_JWKS_MULTIPLE_MATCHING_KEYS (AsyncIterable dari CryptoKey yang cocok)—lakukan iterasi for 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']):

  1. Tanda tangan EVT (verifier): Verifikasi terhadap issuerJwks (menangkap ERR_JWKS_MULTIPLE_MATCHING_KEYS untuk melakukan iterasi kunci kandidat saat kid dihilangkan), menerapkan iss dan periode keaktualan yang ketat (maxTokenAge: '5m', clockTolerance: '1m').
  2. Binding pemegang (opsi kbVerifier dan sdJwt.verify): Ekstrak kunci publik sementara browser dari evtPayload.cnf.jwk, konfirmasi bahwa cnf.jwk.alg dan decoded.kbJwt.header.alg ada di ALLOWED_ALGS dan cocok (memperlakukan 'Ed25519' dan 'EdDSA' sebagai setara saat penerapan Uji Coba Origin bertransisi ke RFC 9864 'Ed25519'), dan verifikasi tanda tangan KB-JWT dengan typ: 'kb+jwt'. Teruskan keyBindingNonce: expectedNonce, expectedKeyBindingAudience: expectedAudience, dan keyBindingMaxAgeSeconds: 300 ke sdJwt.verify()—@sd-jwt/core hanya memanggil kbVerifier dan memvalidasi nonce, aud, iat, dan sd_hash saat keyBindingNonce diberikan.
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/core dan jose di Node.js) guna mengurai dan memvalidasi presentasi EVT~KB-JWT, serta meneruskan keyBindingNonce ke sdJwt.verify() agar verifikasi Key Binding tidak dapat dilewati. JANGAN membuat rutinitas penguraian dan verifikasi tanda tangan JWT, SD-JWT, atau sd_hash kustom secara manual.
  • LAKUKAN pembuatan nonce kriptografis baru (minimal 128 bit, seperti randomBytes(32).toString('base64url')) di server untuk setiap render formulir, simpan di status sesi sisi server (bukan langsung di cookie klien), dan tayangkan halaman formulir dengan Cache-Control: no-store.
  • LAKUKAN pembacaan dan penghapusan nonce yang diharapkan secara atomik dari penyimpanan sesi sisi server segera setelah permintaan POST tiba sehingga token yang disadap tidak dapat diputar ulang.
  • LAKUKAN perbandingan yang tidak peka huruf besar/kecil antara evtPayload.email dan alamat email formulir yang dikirimkan (email.toLowerCase()), sambil mempertahankan string email yang dikirimkan.
  • LAKUKAN verifikasi uji coba semua kunci di JWKS penerbit jika kid tidak ada di header EVT (penyedia seperti Gmail menghilangkan kid).
  • LAKUKAN verifikasi delegasi DNS (_email-verification.<domain>) sebelum memercayai klaim iss apa pun dalam EVT, dan konfirmasi metadata.issuer === iss dalam 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 token kosong 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 nilai token kosong.
  • Domain email yang tidak diautentikasi atau tidak berpartisipasi: Jika pengguna memasukkan alamat email yang domainnya tidak memublikasikan data TXT DNS _email-verification, atau jika pengguna tidak memiliki sesi aktif dengan penyedia emailnya di browser, browser akan mengirimkan formulir dengan token kosong.
  • Token yang sudah tidak berlaku atau tidak valid: Jika nonce sudah tidak berlaku, sudah digunakan, atau gagal dalam langkah verifyEmailVerificationToken apa pun di handleSignupSubmission, server akan menangkap error dan mengirim OTP tradisional atau email magic link tanpa mengganggu alur pendaftaran atau login pengguna.