Wenn Sie bei der Registrierung, Anmeldung, Bezahlung oder Kontowiederherstellung eine E‑Mail-Adresse erfassen, müssen Sie die Inhaberschaft normalerweise durch Senden eines Out-of-Band-Einmalpassworts (OTP) oder eines Magic Links bestätigen. Nutzer müssen Ihre Seite verlassen, auf die E-Mail warten und Codes kopieren. Das führt zu Reibungsverlusten, Abbrüchen und Phishing-Risiken. Außerdem erfährt der E-Mail-Anbieter, welche vertrauende Seite (Relying Party, RP) der Nutzer besucht.
Mit der E‑Mail-Bestätigungs-API (clientseitige HTML-Erweiterung) und dem Email Verification Protocol (EVP) (kryptografisches Backend-Protokoll) kann eine vertrauende Partei prüfen, ob ein Nutzer eine E‑Mail-Adresse kontrolliert, ohne eine Bestätigungs-E‑Mail zu senden. Der Browser vermittelt zwischen der vertrauenden Partei und dem E‑Mail-Anbieter des Nutzers (dem Aussteller):
- Die vertrauende Seite fügt ein ausgeblendetes
<input>mitautocomplete="email-verification-token"und eine einmalige kryptografischenoncein dasselbe<form>wie die E‑Mail-Eingabe ein. - Wenn der Nutzer eine E‑Mail-Adresse auswählt oder eingibt, sucht der Browser nach dem
_email-verification.<domain>-DNS-TXT-Eintrag der Domain, prüft, ob der Nutzer eine aktive Sitzung beim autoritativen Aussteller hat, und fordert ein E‑Mail-Bestätigungstoken (EVT) an, ohne die Identität der vertrauenden Partei gegenüber dem Aussteller offenzulegen. - Der Browser bindet das EVT an den Ursprung und die Sitzung
nonceder vertrauenden Seite in einem Key Binding JWT (KB-JWT), das mit einem sitzungsspezifischen Browser-Schlüsselpaar (EVT + KB-JWT) signiert ist, füllt die ausgeblendete Eingabe aus und sendet das Formular. - Das Backend der vertrauenden Seite überprüft die kryptografische Kette und überspringt, wenn sie gültig ist, den Schritt mit dem E‑Mail-OTP oder dem Magic Link.
1. Origin Trial aktivieren und Bestätigungsformular rendern
So nehmen Sie als bestätigende Website (relying party) teil:
- Geben Sie das Ursprungstest-Token auf der Seite an, auf der das Formular gehostet wird. Verwenden Sie dazu entweder den
Origin-TrialHTTP-Antwortheader oder ein<meta http-equiv="origin-trial">-Element.- Ursprungstests von Drittanbietern: Eingebettete Identitäts-SDKs können ein Drittanbieter-Test-Token einfügen, sofern der Ursprung des Ursprungstest-Registranten mit der Domain des Ausstellers übereinstimmt (gleiche Website wie der Aussteller, z. B.
https://issuer.example).
- Ursprungstests von Drittanbietern: Eingebettete Identitäts-SDKs können ein Drittanbieter-Test-Token einfügen, sofern der Ursprung des Ursprungstest-Registranten mit der Domain des Ausstellers übereinstimmt (gleiche Website wie der Aussteller, z. B.
- Generieren Sie pro Formular-Rendering eine kryptografisch zufällige Nonce mit mindestens 128 Bit Entropie (z. B.
randomBytes(32).toString('base64url')ausnode:crypto), speichern Sie sie im serverseitigen Sitzungsstatus, der durch einHttpOnly-,Secure- oderSameSite=Lax-Sitzungs-ID-Cookie verschlüsselt ist (speichern Sie den erwartetennonce-Wert niemals direkt in einem Client-Cookie, wo ein Angreifer sowohl das Cookie als auch das Token wiedergeben könnte), und rendern Sie sie in dasnonce-Attribut der ausgeblendeten Token-Eingabe. Stellen Sie die Seite mitCache-Control: no-storebereit, damit bei der Vor- und Zurück-Navigation keine bereits verwendete Nonce wiederverwendet wird. - Fügen Sie die Eingabe für das ausgeblendete Token (
autocomplete="email-verification-token") in dasselbe<form>-Element ein wie die Eingabe für die E‑Mail-Adresse (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. Gesendetes Token auf dem Server prüfen
Wenn das Formular gesendet wird, behandeln Sie das Feld token als nicht vertrauenswürdige Eingabe. Lies und lösche den erwarteten nonce nach Erhalt atomar aus deinem serverseitigen Sitzungsspeicher. Wenn das Feld token leer ist (z. B. in Browsern, die EVP nicht unterstützen, oder wenn der Nutzer bei seinem E‑Mail-Anbieter abgemeldet ist) oder die Bestätigung fehlschlägt, greifen Sie sofort auf Ihren Standard-E‑Mail-OTP- oder Magic-Link-Ablauf zurück:
// 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);
}
Verwenden Sie für Ihre Backend-Plattform immer etablierte SD-JWT- und JOSE/JWT-Bibliotheken (z. B. @sd-jwt/core und jose in Node.js, sd-jwt-python / jwcrypto in Python oder sd-jwt-java / Nimbus JOSE+JWT auf der JVM), um verifyEmailVerificationToken zu implementieren, anstatt benutzerdefinierten JWT- oder SD-JWT-Parsing-Code manuell zu erstellen.
Schritt 1: SD-JWT-Anforderungen parsen und E-Mail-Adresse validieren
Nachdem Sie die Einmal-expectedNonce aus dem serverseitigen Sitzungsspeicher abgerufen haben, decodieren Sie die SD-JWT-Präsentation (EVT + KB-JWT) und prüfen Sie Folgendes:
- Sowohl das Aussteller-JWT (
jwt) als auch das Schlüsselbindungs-JWT (kbJwt) sind vorhanden, zusammen mit dem Ausstelleranspruch (iss). evtPayload.email_verified === true.evtPayload.emailstimmt mit der im Formular angegebenen E-Mail-Adresse überein, wobei die Groß-/Kleinschreibung nicht berücksichtigt wird (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.');
}
Schritt 2: DNS-TXT-Delegierung validieren und JWKS des Ausstellers laden
Bevor Sie eine Netzwerkanfrage an iss senden, prüfen Sie, ob die Domain der eingereichten E-Mail-Adresse die Berechtigung über einen _email-verification.<domain>-DNS-TXT-Eintrag an genau diesen Aussteller delegiert (iss=<issuer-host> -> https://<issuer-host> ohne Port, Pfad oder abschließenden Schrägstrich). Sobald die DNS-Delegierung Byte für Byte mit iss übereinstimmt, rufen Sie ${iss}/.well-known/email-verification über HTTPS ab, bestätigen Sie metadata.issuer === iss und laden Sie das JSON Web Key Set (JWKS) des Ausstellers.
Hinweis zur Verarbeitung von
kid: Einige große E‑Mail-Anbieter (z. B. Gmail) lassenkidim EVT-Header weg. Wennkidfehlt und das JWKS mehrere Schlüssel enthält, gibtjoseERR_JWKS_MULTIPLE_MATCHING_KEYS(einAsyncIterablemit übereinstimmendenCryptoKeys) aus. Iterieren Siefor await (const publicKey of err), um jeden Schlüssel zu prüfen, wie in Schritt 3 gezeigt.
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));
Schritt 3: EVT-Signatur und Key Binding JWT (KB-JWT) prüfen
Prüfen Sie abschließend beide Signaturen und die kryptografische Bindung zwischen den beiden Tokens mithilfe der expliziten Zulassungsliste für asymmetrische Algorithmen (['Ed25519', 'EdDSA', 'ES256']):
- EVT-Signatur (
verifier): MitissuerJwksabgleichen (ERR_JWKS_MULTIPLE_MATCHING_KEYSabfangen, um Kandidatenschlüssel zu iterieren, wennkidweggelassen wird),issund ein enges Aktualitätsfenster (maxTokenAge: '5m',clockTolerance: '1m') erzwingen. - Inhaberbindung (Optionen
kbVerifierundsdJwt.verify): Extrahieren Sie den sitzungsspezifischen öffentlichen Schlüssel des Browsers ausevtPayload.cnf.jwk, bestätigen Sie, dass sowohlcnf.jwk.algals auchdecoded.kbJwt.header.alginALLOWED_ALGSenthalten sind und übereinstimmen (behandeln Sie'Ed25519'und'EdDSA'als gleichwertig, während die Ursprungstest-Implementierungen auf RFC 9864'Ed25519'umgestellt werden), und überprüfen Sie dieKB-JWT-Signatur mittyp: 'kb+jwt'. Übergeben SiekeyBindingNonce: expectedNonce,expectedKeyBindingAudience: expectedAudienceundkeyBindingMaxAgeSeconds: 300ansdJwt.verify().@sd-jwt/coreruft nurkbVerifierauf und validiertnonce,aud,iatundsd_hash, wennkeyBindingNonceangegeben ist.
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,
});
Best Practices
- DO perform all token verification strictly on the server. Validieren Sie Tokens NICHT in clientseitigem JavaScript und vertrauen Sie keinem clientseitigen Bestätigungsstatus. Clientseitige Prüfungen können leicht umgangen oder gefälscht werden und bieten der vertrauenden Partei keine Vertrauensgarantie.
- DO use established SD-JWT and JOSE/JWT libraries for your backend platform (for example,
@sd-jwt/coreandjosein Node.js) to parse and validate theEVT~KB-JWTpresentation, and passkeyBindingNoncetosdJwt.verify()so Key Binding verification cannot be bypassed. Erstellen Sie KEINE eigenen Routinen zum Parsen und zur Signaturprüfung von JWTs, SD-JWTs odersd_hashs. - DO generate a fresh cryptographic
nonce(at least 128 bits, such asrandomBytes(32).toString('base64url')) on the server for each form render, store it in server-side session state (not directly in a client cookie), and serve the form page withCache-Control: no-store. - EMPFOHLEN: Lesen und löschen Sie die erwartete
noncesofort atomar aus dem serverseitigen Sitzungsspeicher, wenn diePOST-Anfrage eingeht, damit ein abgefangenes Token nicht wiedergegeben werden kann. - FÜHREN SIE einen Vergleich ohne Berücksichtigung der Groß-/Kleinschreibung zwischen
evtPayload.emailund der im Formular angegebenen E-Mail-Adresse (email.toLowerCase()) durch, wobei der im Formular angegebene E-Mail-String beibehalten wird. - PRÜFEN Sie alle Schlüssel im JWKS des Ausstellers, wenn
kidim EVT-Header fehlt. Anbieter wie Gmail lassenkidweg. - PRÜFEN Sie die DNS-Delegierung (
_email-verification.<domain>), bevor Sie eineriss-Behauptung in einem EVT vertrauen, und bestätigen Siemetadata.issuer === issin der.well-known/email-verification-Antwort. - Betrachten Sie die Bestätigung der Inhaberschaft einer E-Mail-Adresse NICHT als Nachweis der Zustellbarkeit im Posteingang. EVP beweist, dass der Nutzer das E‑Mail-Konto beim autoritativen Anbieter kontrolliert. Senden Sie weiterhin transaktionale Willkommens-E‑Mails und behandeln Sie Unzustellbarkeitsnachrichten wie gewohnt.
- Blockieren Sie die Kontoerstellung oder Anmeldung NICHT, wenn
tokenleer ist oder die Bestätigung fehlschlägt. Verwenden Sie in solchen Fällen immer Ihr Standardverfahren mit OTP per E-Mail oder Magic Link.
Fallback-Strategie
Die E‑Mail-Bestätigung (autocomplete="email-verification-token") ist als progressive Verbesserung gegenüber der herkömmlichen Out-of-Band-E‑Mail-Bestätigung (OTP-Codes oder Magic Links) konzipiert:
- Nicht unterstützte Browser: Browser, die
autocomplete="email-verification-token"nicht unterstützen, ignorieren die ausgeblendete Eingabe und senden das Formular mit einem leerentoken-Wert. - Nicht authentifizierte oder nicht teilnehmende E‑Mail-Domains: Wenn ein Nutzer eine E‑Mail-Adresse eingibt, deren Domain keinen
_email-verification-DNS-TXT-Eintrag veröffentlicht, oder wenn der Nutzer keine aktive Sitzung bei seinem E‑Mail-Anbieter im Browser hat, sendet der Browser das Formular mit einem leerentoken. - Abgelaufene oder ungültige Tokens: Wenn das
nonceabgelaufen ist, bereits verwendet wurde oder einen Schritt vonverifyEmailVerificationTokeninhandleSignupSubmissionnicht besteht, fängt der Server den Fehler ab und sendet eine herkömmliche OTP- oder Magic Link-E‑Mail, ohne den Registrierungs- oder Anmeldevorgang des Nutzers zu unterbrechen.