Potwierdzanie własności adresu e-mail

Gdy zbierasz adresy e-mail podczas rejestracji, logowania, płatności lub odzyskiwania konta, weryfikacja własności tradycyjnie wymaga wysłania jednorazowego kodu dostępu lub magicznego linku. Zmusza to użytkowników do opuszczenia strony, czekania na dostarczenie e-maila i skopiowania kodów, co powoduje problemy, rezygnację i ryzyko phishingu, a także ujawnia dostawcy poczty e-mail, którą stronę zależną odwiedza użytkownik.

Interfejs API do potwierdzania adresu e-mail (rozszerzenie HTML po stronie klienta) i protokół potwierdzania adresu e-mail (EVP) (protokół kryptograficzny backendu) umożliwiają stronie zależnej sprawdzenie, czy użytkownik kontroluje adres e-mail, bez wysyłania e-maila weryfikacyjnego. Przeglądarka pośredniczy między stroną uzależnioną a dostawcą poczty e-mail użytkownika (wydawcą):

  1. Strona uzależniona zawiera ukryty element <input> z atrybutem autocomplete="email-verification-token" i jednorazowy kryptograficzny element nonce w tym samym elemencie <form> co pole do wpisania adresu e-mail.
  2. Gdy użytkownik wybierze lub wpisze adres e-mail, przeglądarka wyszuka rekord DNS _email-verification.<domain> domeny TXT, sprawdzi, czy użytkownik ma aktywną sesję z autorytatywnym wystawcą, i poprosi o token weryfikacji adresu e-mail (EVT) bez ujawniania wystawcy tożsamości strony uzależnionej.
  3. Przeglądarka wiąże EVT ze źródłem i sesją (nonce) strony zależnej w tokenie JWT powiązanym z kluczem (KB-JWT) podpisanym tymczasową parą kluczy przeglądarki (EVT + KB-JWT), wypełnia ukryte pole i przesyła formularz.
  4. Backend strony zależnej weryfikuje łańcuch kryptograficzny i jeśli jest on prawidłowy, pomija krok z hasłem jednorazowym wysyłanym e-mailem lub magicznym linkiem.

1. Włączanie testowania origin i renderowanie formularza weryfikacyjnego

Aby uczestniczyć jako witryna weryfikująca (strona uzależniona):

  1. Podaj token testowania origin na stronie hostującej formularz, używając nagłówka odpowiedzi HTTP Origin-Trial lub elementu <meta http-equiv="origin-trial">.
    • Wersje próbne origin innych firm: umieszczone pakiety SDK tożsamości mogą wstrzykiwać token wersji próbnej innej firmy, pod warunkiem że źródło rejestrującego wersję próbną origin jest zgodne z domeną wydawcy (ta sama witryna co wydawca, np. https://issuer.example).
  2. Wygeneruj kryptograficznie losową liczbę jednorazową na każde renderowanie formularza z co najmniej 128-bitową entropią (np. randomBytes(32).toString('base64url') z node:crypto), zapisz ją w stanie sesji po stronie serwera z kluczem w postaci pliku cookie identyfikatora sesji HttpOnly, Secure, SameSite=Lax (nigdy nie zapisuj oczekiwanej wartości nonce bezpośrednio w pliku cookie klienta, gdzie osoba przeprowadzająca atak mogłaby odtworzyć zarówno plik cookie, jak i token) i wyrenderuj ją w atrybucie nonce ukrytego tokena wejściowego. Wyświetlaj stronę z nagłówkiem Cache-Control: no-store, aby nawigacja wstecz/dalej nie używała ponownie już wykorzystanej liczby jednorazowej.
  3. Dodaj ukryte pole tokena (autocomplete="email-verification-token") w tym samym elemencie <form> co pole adresu e-mail (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. Weryfikowanie przesłanego tokena na serwerze

Po przesłaniu formularza traktuj pole token jako niezaufane dane wejściowe. Po otrzymaniu odczytaj i usuń oczekiwany parametr nonce z magazynu sesji po stronie serwera. Jeśli pole token jest puste (np. w przeglądarkach, które nie obsługują EVP, lub gdy użytkownik jest wylogowany z konta u dostawcy poczty e-mail) lub jeśli weryfikacja się nie powiedzie, natychmiast przejdź do standardowego procesu weryfikacji za pomocą jednorazowego hasła wysłanego e-mailem lub magicznego linku:

// 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);
}

Zawsze używaj sprawdzonych bibliotek SD-JWT i JOSE/JWT dla platformy backendowej (takich jak @sd-jwt/core i jose w Node.js, sd-jwt-python / jwcrypto w Pythonie lub sd-jwt-java / Nimbus JOSE+JWT na JVM), aby wdrożyć verifyEmailVerificationToken, zamiast ręcznie tworzyć niestandardowy kod do analizy JWT lub SD-JWT.

Krok 1. Przeanalizuj oświadczenia SD-JWT i zweryfikuj adres e-mail

Po wykorzystaniu jednorazowego elementu expectedNonce z pamięci sesji po stronie serwera zdekoduj prezentację SD-JWT (EVT + KB-JWT) i sprawdź, czy:

  • Dostępne są zarówno token JWT wydawcy (jwt), jak i token JWT powiązania klucza (kbJwt), a także deklaracja wydawcy (iss).
  • evtPayload.email_verified === true.
  • evtPayload.email jest zgodny z adresem e-mail podanym w formularzu, przy czym wielkość liter nie jest rozróżniana (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.');
}

Krok 2. Zweryfikuj delegowanie DNS TXT i wczytaj JWKS wydawcy

Zanim wyślesz żądanie sieciowe do iss, sprawdź, czy domena przesłanego adresu e-mail przekazuje uprawnienia do tego konkretnego wystawcy za pomocą rekordu TXT _email-verification.<domain> DNS (iss=<issuer-host> -> https://<issuer-host> bez portu, ścieżki ani ukośnika na końcu). Gdy delegowanie DNS będzie zgodne z iss bajt po bajcie, pobierz ${iss}/.well-known/email-verification przez HTTPS, potwierdź, że metadata.issuer === iss, i załadować zestaw kluczy internetowych JSON (JWKS) wydawcy.

Uwaga dotycząca obsługi kid: niektórzy główni dostawcy poczty e-mail (np. Gmail) pomijają kid w nagłówku EVT. Jeśli nie ma parametru kid, a JWKS zawiera wiele kluczy, jose zgłasza błąd ERR_JWKS_MULTIPLE_MATCHING_KEYS (AsyncIterable pasujących kluczy CryptoKey). Wykonaj iterację for await (const publicKey of err), aby sprawdzić każdy klucz, jak pokazano w kroku 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));

Krok 3. Zweryfikuj podpis EVT i token JWT powiązany z kluczem (KB-JWT)

Na koniec zweryfikuj oba podpisy i powiązanie kryptograficzne między 2 tokenami, korzystając z jawnej listy dozwolonych algorytmów asymetrycznych (['Ed25519', 'EdDSA', 'ES256']):

  1. Podpis EVT (verifier): weryfikacja pod kątem issuerJwks (wykrywanie ERR_JWKS_MULTIPLE_MATCHING_KEYS w celu iteracji kluczy kandydujących, gdy pominięto kid), wymuszanie iss i wąskie okno aktualności (maxTokenAge: '5m', clockTolerance: '1m').
  2. Wiązanie posiadacza (opcje kbVerifier i sdJwt.verify): wyodrębnij tymczasowy klucz publiczny przeglądarki z evtPayload.cnf.jwk, potwierdź, że cnf.jwk.alg i decoded.kbJwt.header.alg znajdują się w ALLOWED_ALGS i pasują do siebie (traktuj 'Ed25519' i 'EdDSA' jako równoważne, podczas gdy implementacje testowania origin przechodzą na RFC 9864 'Ed25519'), i zweryfikuj podpis KB-JWT za pomocą typ: 'kb+jwt'. Przekaż keyBindingNonce: expectedNonce, expectedKeyBindingAudience: expectedAudience i keyBindingMaxAgeSeconds: 300 do sdJwt.verify() – @sd-jwt/core wywołuje tylko kbVerifier i weryfikuje nonce, aud, iat i sd_hash, gdy podano keyBindingNonce.
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,
});

Sprawdzone metody

  • ZALECENIE: przeprowadzaj całą weryfikację tokenów wyłącznie na serwerze. NIE weryfikuj tokenów w kodzie JavaScript po stronie klienta ani nie ufaj żadnemu stanowi weryfikacji po stronie klienta – testy po stronie klienta można łatwo obejść lub sfałszować i nie dają one stronie zależnej żadnej gwarancji zaufania.
  • UŻYWAJ sprawdzonych bibliotek SD-JWT i JOSE/JWT na platformie backendu (np. @sd-jwt/core i jose w Node.js), aby analizować i weryfikować prezentację EVT~KB-JWT oraz przekazywać keyBindingNonce do sdJwt.verify(), aby nie można było pominąć weryfikacji powiązania klucza. NIE twórz niestandardowych procedur analizowania i weryfikacji podpisu JWT, SD-JWT ani sd_hash.
  • ZALECANE: wygeneruj na serwerze nowy kryptograficzny token nonce (co najmniej 128 bitów, np. randomBytes(32).toString('base64url')) dla każdego renderowania formularza, zapisz go w stanie sesji po stronie serwera (nie bezpośrednio w pliku cookie klienta) i wyświetl stronę formularza z tokenem Cache-Control: no-store.
  • DO atomically read and delete the expected nonce from server-side session storage immediately when the POST request arrives so an intercepted token cannot be replayed.
  • WYKONAJ porównanie, w którym wielkość liter nie jest rozróżniana między evtPayload.email a adresem e-mail przesłanym w formularzu (email.toLowerCase()), zachowując przesłany ciąg znaków adresu e-mail.
  • DO trial-verify all keys in the issuer's JWKS when kid is absent from the EVT header (providers such as Gmail omit kid).
  • SPRAWDŹ przekazanie DNS (_email-verification.<domain>), zanim zaufasz twierdzeniu iss w EVT, i potwierdź metadata.issuer === iss w odpowiedzi .well-known/email-verification.
  • NIE traktuj weryfikacji własności adresu e-mail jako dowodu dostarczalności do skrzynki odbiorczej. EVP potwierdza, że użytkownik kontroluje konto e-mail u autorytatywnego dostawcy. Nadal wysyłaj transakcyjne e-maile powitalne i obsługuj odesłane wiadomości w normalny sposób.
  • NIE blokuj tworzenia konta ani logowania, gdy parametr token jest pusty lub gdy weryfikacja się nie powiedzie – zawsze przechodź do standardowego procesu z hasłem jednorazowym wysyłanym e-mailem lub magicznym linkiem.

Strategia zastępcza

Potwierdzanie adresu e-mail (autocomplete="email-verification-token") zostało zaprojektowane jako progresywne ulepszanie tradycyjnej weryfikacji adresu e-mail poza pasmem (kody haseł jednorazowych lub magiczne linki):

  • Nieobsługiwane przeglądarki: przeglądarki, które nie obsługują autocomplete="email-verification-token", ignorują ukryte dane wejściowe i przesyłają formularz z pustą wartością token.
  • Nieuwierzytelnione lub nieuczestniczące domeny e-mail: gdy użytkownik wpisze adres e-mail, którego domena nie publikuje rekordu DNS _email-verification TXT, lub gdy użytkownik nie ma aktywnej sesji u swojego dostawcy poczty e-mail w przeglądarce, przeglądarka przesyła formularz z pustym polem token.
  • Wygasłe lub nieprawidłowe tokeny: jeśli token nonce wygasł, został już wykorzystany lub nie przeszedł któregoś z etapów verifyEmailVerificationToken w handleSignupSubmission, serwer wykrywa błąd i wysyła tradycyjny e-mail z hasłem jednorazowym lub magicznym linkiem bez przerywania procesu rejestracji lub logowania użytkownika.