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ą):
- Strona uzależniona zawiera ukryty element
<input>z atrybutemautocomplete="email-verification-token"i jednorazowy kryptograficzny elementnoncew tym samym elemencie<form>co pole do wpisania adresu e-mail. - Gdy użytkownik wybierze lub wpisze adres e-mail, przeglądarka wyszuka rekord DNS
_email-verification.<domain>domenyTXT, 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. - 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. - 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):
- Podaj token testowania origin na stronie hostującej formularz, używając nagłówka odpowiedzi HTTP
Origin-Triallub 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).
- 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.
- Wygeneruj kryptograficznie losową liczbę jednorazową na każde renderowanie formularza z co najmniej 128-bitową entropią (np.
randomBytes(32).toString('base64url')znode:crypto), zapisz ją w stanie sesji po stronie serwera z kluczem w postaci pliku cookie identyfikatora sesjiHttpOnly,Secure,SameSite=Lax(nigdy nie zapisuj oczekiwanej wartościnoncebezpośrednio w pliku cookie klienta, gdzie osoba przeprowadzająca atak mogłaby odtworzyć zarówno plik cookie, jak i token) i wyrenderuj ją w atrybucienonceukrytego tokena wejściowego. Wyświetlaj stronę z nagłówkiemCache-Control: no-store, aby nawigacja wstecz/dalej nie używała ponownie już wykorzystanej liczby jednorazowej. - 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.emailjest 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ąkidw nagłówku EVT. Jeśli nie ma parametrukid, a JWKS zawiera wiele kluczy,josezgłasza błądERR_JWKS_MULTIPLE_MATCHING_KEYS(AsyncIterablepasujących kluczyCryptoKey). 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']):
- Podpis EVT (
verifier): weryfikacja pod kątemissuerJwks(wykrywanieERR_JWKS_MULTIPLE_MATCHING_KEYSw celu iteracji kluczy kandydujących, gdy pominiętokid), wymuszanieissi wąskie okno aktualności (maxTokenAge: '5m',clockTolerance: '1m'). - Wiązanie posiadacza (opcje
kbVerifierisdJwt.verify): wyodrębnij tymczasowy klucz publiczny przeglądarki zevtPayload.cnf.jwk, potwierdź, żecnf.jwk.algidecoded.kbJwt.header.algznajdują się wALLOWED_ALGSi pasują do siebie (traktuj'Ed25519'i'EdDSA'jako równoważne, podczas gdy implementacje testowania origin przechodzą na RFC 9864'Ed25519'), i zweryfikuj podpisKB-JWTza pomocątyp: 'kb+jwt'. PrzekażkeyBindingNonce: expectedNonce,expectedKeyBindingAudience: expectedAudienceikeyBindingMaxAgeSeconds: 300dosdJwt.verify()–@sd-jwt/corewywołuje tylkokbVerifieri weryfikujenonce,aud,iatisd_hash, gdy podanokeyBindingNonce.
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/coreijosew Node.js), aby analizować i weryfikować prezentacjęEVT~KB-JWToraz przekazywaćkeyBindingNoncedosdJwt.verify(), aby nie można było pominąć weryfikacji powiązania klucza. NIE twórz niestandardowych procedur analizowania i weryfikacji podpisu JWT, SD-JWT anisd_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 tokenemCache-Control: no-store. - DO atomically read and delete the expected
noncefrom server-side session storage immediately when thePOSTrequest arrives so an intercepted token cannot be replayed. - WYKONAJ porównanie, w którym wielkość liter nie jest rozróżniana między
evtPayload.emaila 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
kidis absent from the EVT header (providers such as Gmail omitkid). - SPRAWDŹ przekazanie DNS (
_email-verification.<domain>), zanim zaufasz twierdzeniuissw EVT, i potwierdźmetadata.issuer === issw 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
tokenjest 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-verificationTXT, lub gdy użytkownik nie ma aktywnej sesji u swojego dostawcy poczty e-mail w przeglądarce, przeglądarka przesyła formularz z pustym polemtoken. - Wygasłe lub nieprawidłowe tokeny: jeśli token
noncewygasł, został już wykorzystany lub nie przeszedł któregoś z etapówverifyEmailVerificationTokenwhandleSignupSubmission, 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.