Inhaberschaft der E‑Mail-Adresse bestätigen

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

  1. Die vertrauende Seite fügt ein ausgeblendetes <input> mit autocomplete="email-verification-token" und eine einmalige kryptografische nonce in dasselbe <form> wie die E‑Mail-Eingabe ein.
  2. 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.
  3. Der Browser bindet das EVT an den Ursprung und die Sitzung nonce der 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.
  4. 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:

  1. Geben Sie das Ursprungstest-Token auf der Seite an, auf der das Formular gehostet wird. Verwenden Sie dazu entweder den Origin-Trial HTTP-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).
  2. Generieren Sie pro Formular-Rendering eine kryptografisch zufällige Nonce mit mindestens 128 Bit Entropie (z. B. randomBytes(32).toString('base64url') aus node:crypto), speichern Sie sie im serverseitigen Sitzungsstatus, der durch ein HttpOnly-, Secure- oder SameSite=Lax-Sitzungs-ID-Cookie verschlüsselt ist (speichern Sie den erwarteten nonce-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 das nonce-Attribut der ausgeblendeten Token-Eingabe. Stellen Sie die Seite mit Cache-Control: no-store bereit, damit bei der Vor- und Zurück-Navigation keine bereits verwendete Nonce wiederverwendet wird.
  3. 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.email stimmt 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) lassen kid im EVT-Header weg. Wenn kid fehlt und das JWKS mehrere Schlüssel enthält, gibt jose ERR_JWKS_MULTIPLE_MATCHING_KEYS (ein AsyncIterable mit übereinstimmenden CryptoKeys) aus. Iterieren Sie for 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']):

  1. EVT-Signatur (verifier): Mit issuerJwks abgleichen (ERR_JWKS_MULTIPLE_MATCHING_KEYS abfangen, um Kandidatenschlüssel zu iterieren, wenn kid weggelassen wird), iss und ein enges Aktualitätsfenster (maxTokenAge: '5m', clockTolerance: '1m') erzwingen.
  2. Inhaberbindung (Optionen kbVerifier und sdJwt.verify): Extrahieren Sie den sitzungsspezifischen öffentlichen Schlüssel des Browsers aus evtPayload.cnf.jwk, bestätigen Sie, dass sowohl cnf.jwk.alg als auch decoded.kbJwt.header.alg in ALLOWED_ALGS enthalten 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 die KB-JWT-Signatur mit typ: 'kb+jwt'. Übergeben Sie keyBindingNonce: expectedNonce, expectedKeyBindingAudience: expectedAudience und keyBindingMaxAgeSeconds: 300 an sdJwt.verify(). @sd-jwt/core ruft nur kbVerifier auf und validiert nonce, aud, iat und sd_hash, wenn keyBindingNonce angegeben 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/core and jose in Node.js) to parse and validate the EVT~KB-JWT presentation, and pass keyBindingNonce to sdJwt.verify() so Key Binding verification cannot be bypassed. Erstellen Sie KEINE eigenen Routinen zum Parsen und zur Signaturprüfung von JWTs, SD-JWTs oder sd_hashs.
  • DO generate a fresh cryptographic nonce (at least 128 bits, such as randomBytes(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 with Cache-Control: no-store.
  • EMPFOHLEN: Lesen und löschen Sie die erwartete nonce sofort atomar aus dem serverseitigen Sitzungsspeicher, wenn die POST-Anfrage eingeht, damit ein abgefangenes Token nicht wiedergegeben werden kann.
  • FÜHREN SIE einen Vergleich ohne Berücksichtigung der Groß-/Kleinschreibung zwischen evtPayload.email und 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 kid im EVT-Header fehlt. Anbieter wie Gmail lassen kid weg.
  • PRÜFEN Sie die DNS-Delegierung (_email-verification.<domain>), bevor Sie einer iss-Behauptung in einem EVT vertrauen, und bestätigen Sie metadata.issuer === iss in 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 token leer 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 leeren token-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 leeren token.
  • Abgelaufene oder ungültige Tokens: Wenn das nonce abgelaufen ist, bereits verwendet wurde oder einen Schritt von verifyEmailVerificationToken in handleSignupSubmission nicht 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.