<?xml version='1.0' encoding='utf-8'?>
<!DOCTYPE rfc [
  <!ENTITY nbsp    "&#160;">
  <!ENTITY zwsp   "&#8203;">
  <!ENTITY nbhy   "&#8209;">
  <!ENTITY wj     "&#8288;">
]>
<!-- name="GENERATOR" content="github.com/mmarkdown/mmark Mmark Markdown Processor - mmark.miek.nl" -->
<rfc xmlns:xi="http://www.w3.org/2001/XInclude" version="3" ipr="trust200902" docName="draft-hardt-email-verification-00" submissionType="IETF" category="std" xml:lang="en" indexInclude="true">

<front>
<title abbrev="EVP">Email Verification Protocol</title><seriesInfo value="draft-hardt-email-verification-00" stream="IETF" status="standard" name="Internet-Draft"/>
<author initials="D." surname="Hardt" fullname="Dick Hardt"><organization>Hellō</organization><address><postal><street/>
</postal><email>dick.hardt@gmail.com</email>
</address></author><author initials="S." surname="Goto" fullname="Sam Goto"><organization>Google</organization><address><postal><street/>
</postal><email>goto@google.com</email>
</address></author><date/>
<area>Security</area>
<workgroup>TBD</workgroup>
<keyword>email</keyword>
<keyword>verification</keyword>
<keyword>identity</keyword>
<keyword>authentication</keyword>

<abstract>
<t>This document defines the Email Verification Protocol (EVP), the HTTP-level protocol by which a browser obtains a signed email verification token from an issuer and presents it to a relying party (RP). The protocol enables web applications to verify that a user controls an email address without sending a verification email. It uses a three-party model in which the browser intermediates between the RP and the issuer, hiding the RP's identity from the issuer and supporting private, per-RP email addresses to prevent cross-site correlation.</t>
<t>This document covers issuer discovery, the token issuance request, the Email Verification Token (EVT) and Key Binding JWT (KB-JWT) formats, and token verification. The browser API — how the user selects an email address and how the token is delivered to the RP — is defined in the companion W3C Email Verification API (<xref target="EVP-Browser"/>).</t>
</abstract>

<note><name>Discussion Venues</name>
<t><em>Note: This section is to be removed before publishing as an RFC.</em></t>
<t>Source for this draft and an issue tracker can be found at <eref target="https://github.com/dickhardt/email-verification">https://github.com/dickhardt/email-verification</eref>.</t>
<t>The browser API aspects are being developed separately by the W3C (<xref target="EVP-Browser"/>).</t>
</note>

</front>

<middle>

<section anchor="introduction"><name>Introduction</name>
<t>Web applications verify email addresses to send emails to users (transactional notifications, marketing, password resets) and to identify users (as a stable identifier for account creation and authentication). The standard verification method—sending a one-time code via email—has two problems: verification friction and privacy leakage.</t>

<section anchor="verification-friction"><name>Verification Friction</name>
<t>The email one-time code flow requires the user to switch to their email client, wait for the message to arrive, find it (possibly in spam), read the code, return to the application, and enter it. Many users abandon this process before completing it.</t>
<t>Some approaches to reduce this friction:</t>

<ul>
<li><t><strong>Social login</strong>: When a user has an account with Google, Apple, or another identity provider, the application can obtain a verified email without sending a verification message. However, this requires the user to have and use a social account, and requires developers to integrate with each provider separately.</t>
</li>
<li><t><strong>Magic links</strong>: Instead of a code, the verification email contains a link the user clicks to verify. This eliminates copying and pasting the code, but still requires switching to the email client, waiting for delivery, and finding the email.</t>
</li>
</ul>
</section>

<section anchor="privacy-leakage"><name>Privacy Leakage</name>
<t>Email verification creates two privacy problems:</t>

<ol>
<li><t><strong>RP-to-RP correlation</strong>: When a user provides their real email address to multiple relying parties (RPs), those RPs can correlate the user across sites by comparing email addresses.</t>
</li>
<li><t><strong>User-RP visibility</strong>: The email provider learns which RPs the user visits and when. With email OTP, the provider sees verification emails from the sender and the delivery timing for every verification. With social login, the identity provider sees every RP request.</t>
</li>
</ol>
<t>Reducing friction in email verification accelerates both privacy problems — users verify to more sites, increasing correlation potential and provider visibility.</t>
</section>

<section anchor="friction-solution"><name>Friction Solution</name>
<t>The Email Verification Protocol (EVP) enables a web application to obtain a verified email address <strong>without sending an email</strong> and <strong>without the user leaving the web page</strong>. The browser intermediates between the RP and an issuer, obtaining a signed token that contains an email address for the user that the RP can verify. This eliminates the email delivery step entirely.</t>
<t><strong>Note on deliverability</strong>: Like social login, this protocol verifies that the user controls an email address — it does not verify that the email address can receive mail.</t>
</section>

<section anchor="privacy-solution"><name>Privacy Solution</name>
<t>EVP addresses both privacy problems:</t>
<t><strong>Three-party model</strong>: The browser intermediates between the RP and issuer, ensuring the issuer never learns which RP requested verification. See <eref target="#protocol-flow">Protocol Flow</eref> for details.</t>
<t><strong>Private email addresses</strong>: The browser can request a private email address instead of the user's actual email. Private addresses that differ per RP cannot be correlated across sites. See <eref target="#private-email">Private Email Addresses</eref> for details.</t>
</section>
</section>

<section anchor="protocol-flow"><name>Protocol Flow</name>
<t>This document specifies the IETF protocol aspects of email verification: the HTTP-level interactions between the browser, issuer, and the application, aka relying party (RP). How the browser obtains the email address from the user (browser APIs, user interface elements, etc.) and how the browser communicates with the RP is being defined by the W3C (<xref target="EVP-Browser"/>).</t>

<ul>
<li><t><strong>Issuer</strong>: The service that verifies the user controls an email address. See <eref target="#issuer-discovery">Issuer Discovery</eref> for how email domains delegate to issuers.</t>
</li>
<li><t><strong>Three-party model</strong>: The protocol uses a three-party model where the browser intermediates between the RP and issuer. The issuer issues a email verification token (EVT) to the browser containing the email address and the browser's key material—but not the RP identity. The browser then creates a key binding token (KB-JWT) that ties the EVT to a specific RP. The combined token (EVT+KB) is what the RP receives. This separation hides the RP from the issuer during verification.</t>
</li>
</ul>
<t>The following diagram illustrates the protocol flow between the RP Server, Browser, and Issuer:</t>

<artwork><![CDATA[Step                      RP Server     Browser              Issuer
                               |            |                    |
2.1 Email Discovery            |            |<- discover accts ->|
                               |            |   (push or pull)   |
                               |            |                    |
2.2 Session Binding            |--- nonce ->|                    |
                               |            |                    |
2.3 Email Acquisition          |      [obtain email from user]   |
                               |            |                    |
2.4 Token Request              |            |-- POST /issuance ->|
                               |            |    (email, ...)    |
                               |            |                    |
2.5 EVT Creation               |            |           [create EVT]
                               |            |                    |
2.6 Token Issuance             |            |<------ EVT --------|
                               |            |                    |
2.7 KB Creation                |        [create KB-JWT]          |
                               |            |                    |
2.8 Token Presentation         |<-- EVT+KB -|                    |
                               |            |                    |
2.9 Token Verification    [verify EVT+KB]   |                    |
                               |            |                    |
]]>
</artwork>

<section anchor="email-discovery"><name>Email Discovery</name>
<t>Before presenting the user with email address choices, the browser discovers which email accounts have active sessions with an issuer. There are two models for this discovery:</t>
<t><strong>Push model</strong>: The issuer proactively pushes account information to the browser using the FedCM Accounts Push mechanism (<xref target="LightweightFedCM"/>). The browser accumulates account data without making an explicit request.</t>
<t><strong>Pull model</strong>: The browser calls the issuer's accounts endpoint per the FedCM mechanism, as defined in the W3C Email Verification API (<xref target="EVP-Browser"/>). The browser fetches the issuer's FedCM well-known configuration and requests the account list from the <tt>accounts_endpoint</tt>.</t>
<t>The result of email discovery is the set of email addresses available for the current user, which the browser presents to the user in <eref target="#email-acquisition">Email Acquisition</eref>.</t>
</section>

<section anchor="session-binding"><name>Session Binding</name>
<t>The RP Server generates a cryptographically random nonce with at least 128 bits of entropy and binds it to a session it has with the browser. The nonce MUST be unique per verification request and SHOULD be valid for a limited time window. How the RP Server provides the nonce to the browser is being defined by the W3C (<xref target="EVP-Browser"/>).</t>
</section>

<section anchor="email-acquisition"><name>Email Acquisition</name>
<t>The browser obtains an email address from the user. This mechanism is being defined by the W3C (<xref target="EVP-Browser"/>).</t>
</section>

<section anchor="token-request"><name>Token Request</name>
<t>Once the browser has the email address and nonce:</t>

<ol>
<li><t>The browser performs <eref target="#issuer-discovery">Issuer Discovery</eref> for the email address to obtain the issuer's metadata, including the <tt>issuance_endpoint</tt>.</t>
</li>
<li><t>The browser generates a fresh private/public key pair. The browser SHOULD select an algorithm from the issuer's <tt>signing_alg_values_supported</tt> array, or use "EdDSA" if not present.</t>
</li>
<li><t>The browser creates a signed request per <eref target="#http-signatures">HTTP Message Signatures</eref> and POSTs to the <tt>issuance_endpoint</tt>, including the issuer's cookies. The request body is a JSON object with the following parameters:</t>

<ul spacing="compact">
<li><tt>email</tt> (REQUIRED): The email address to verify</li>
<li>See <eref target="#private-email">Private Email Addresses</eref> for parameters to request private email addresses</li>
<li>See <eref target="#webauthn-authentication">WebAuthn Authentication</eref> for parameters to respond to a WebAuthn challenge</li>
</ul></li>
</ol>

<sourcecode type="http"><![CDATA[POST /email-verification/issuance HTTP/1.1
Host: accounts.issuer.example
Cookie: session=...
Content-Type: application/json
Sec-Fetch-Dest: email-verification
Signature-Input: sig=("@method" "@authority" "@path" \
    "cookie" "signature-key");created=1692345600
Signature: sig=:MEQCIHd8Y8qYKm5e3dV8y....:
Signature-Key: sig=hwk; kty="OKP"; crv="Ed25519"; \
    x="JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"

{"email":"user@example.com"}
]]>
</sourcecode>
<blockquote><t>Note: The W3C Email Verification API (<xref target="EVP-Browser"/>) currently specifies a different request format: the browser constructs a signed JWT (<tt>request_token</tt>) with the public key in the header (<tt>alg</tt>, <tt>jwk</tt>) and <tt>aud</tt>, <tt>iat</tt>, and <tt>email</tt> in the payload, sent with <tt>Content-Type: application/x-www-form-urlencoded</tt>. Existing deployments use this format; it is considered deprecated by this specification in favor of HTTP Message Signatures.</t>
</blockquote></section>

<section anchor="evt-issuance"><name>EVT Issuance</name>
<t>On receipt of a token request:</t>

<ol>
<li><t>The issuer verifies the request per <eref target="#request-verification">Request Verification</eref>.</t>
</li>
<li><t>The issuer checks if the cookies represent a logged-in user who controls the requested email address. If the issuer supports WebAuthn (<tt>webauthn_supported: true</tt>) and cookies are not present or invalid, the issuer MAY return a WebAuthn challenge (see <eref target="#webauthn-authentication">WebAuthn Authentication</eref>).</t>
</li>
<li><t>If authentication succeeds, the issuer creates an EVT per <eref target="#evt-creation">EVT Creation</eref> and returns it as the value of <tt>issuance_token</tt> in an <tt>application/json</tt> response. The issuer MAY include <tt>Set-Cookie</tt> headers to establish or update session state:</t>
</li>
</ol>

<sourcecode type="http"><![CDATA[HTTP/1.1 200 OK
Content-Type: application/json
Set-Cookie: session=...; Secure; HttpOnly; SameSite=None

{"issuance_token":"eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjQtMDgtMTkiLCJ0eXAiOiJldnQrand0In0...~"}
]]>
</sourcecode>
<t>The browser MUST process any <tt>Set-Cookie</tt> headers in the response.</t>
</section>

<section anchor="kb-creation"><name>KB Creation</name>
<t>On receiving the <tt>issuance_token</tt>:</t>

<ol>
<li><t>The browser verifies the EVT per <eref target="#evt-verification">EVT Verification</eref>, additionally confirming:</t>

<ul spacing="compact">
<li>The <tt>email</tt> claim matches the email address being verified</li>
<li>The <tt>cnf.jwk</tt> claim matches the public key the browser generated</li>
</ul></li>
<li><t>The browser creates a KB-JWT per <eref target="#kb-creation-detail">KB-JWT Creation</eref>, binding the EVT to the RP's origin and session nonce.</t>
</li>
<li><t>The browser concatenates the EVT and KB-JWT to form the EVT+KB.</t>
</li>
</ol>
<t>Example EVT+KB (line breaks for display):</t>

<artwork><![CDATA[eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjQtMDgtMTkiLCJ0eXAiOiJldnQrand0In0.
eyJpc3MiOiJpc3N1ZXIuZXhhbXBsZSIsImlhdCI6MTcyNDA4MzIwMCwiY25mIjp7...}.
signature~
eyJhbGciOiJFZERTQSIsInR5cCI6ImtiK2p3dCJ9.
eyJhdWQiOiJodHRwczovL3JwLmV4YW1wbGUiLCJub25jZSI6IjI1OWM1ZWFlLTQ4...}.
signature
]]>
</artwork>
</section>

<section anchor="token-presentation"><name>Token Presentation</name>
<t>The browser provides the EVT+KB to the RP. This mechanism is being defined by the W3C (<xref target="EVP-Browser"/>).</t>
</section>

<section anchor="token-verification"><name>Token Verification</name>
<t>The RP receives the EVT+KB and verifies it by:</t>

<ol spacing="compact">
<li>Verifying the KB-JWT per <eref target="#kb-verification">KB-JWT Verification</eref></li>
<li>Verifying the EVT per <eref target="#evt-verification">EVT Verification</eref></li>
<li>Verifying the KB-JWT signature using the public key from the EVT's <tt>cnf.jwk</tt> claim</li>
</ol>
<t>If all verification steps pass, the RP has successfully verified that the user controls the email address in the <tt>email</tt> claim.</t>
</section>
</section>

<section anchor="issuer-discovery"><name>Issuer Discovery</name>
<t>Both the browser and the RP need to discover information about the issuer for a given email address. This section describes the discovery process.</t>
<blockquote><t>Note: The W3C Email Verification API (<xref target="EVP-Browser"/>) uses FedCM's well-known configuration (<tt>/.well-known/fedcm.json</tt>) for issuer discovery. This specification uses <tt>/.well-known/email-verification</tt>. These two specifications will align on a single well-known file.</t>
</blockquote>
<section anchor="dns-delegation"><name>DNS Delegation</name>
<t>The email domain delegates email verification to an issuer via a DNS TXT record. Given an email address, parse the email domain (<tt>EMAIL_DOMAIN) and look up the `TXT` record for `_email-verification.</tt>EMAIL_DOMAIN<tt>. The contents of the record MUST start with</tt>iss=<tt>followed by the issuer identifier. There MUST be only one</tt>TXT<tt>record for</tt>_email-verification.$EMAIL_DOMAIN`.</t>
<t>Example record:</t>

<sourcecode type="bash"><![CDATA[_email-verification.email-domain.example   TXT   iss=issuer.example
]]>
</sourcecode>
<t>This record states that <tt>email-domain.example</tt> has delegated email verification to the issuer <tt>issuer.example</tt>.</t>
<t>If the email domain and the issuer are the same domain, then the record would be:</t>

<sourcecode type="bash"><![CDATA[_email-verification.issuer.example   TXT   iss=issuer.example
]]>
</sourcecode>
<blockquote><t>Access to DNS records and email is often independent of website deployments. This provides assurance that an issuer is truly authorized as an insider with only access to websites on <tt>issuer.example</tt> could not setup an issuer that would grant them verified emails for any email at <tt>issuer.example</tt>.</t>
</blockquote></section>

<section anchor="issuer-metadata"><name>Issuer Metadata</name>
<t>Once the issuer identifier is known, fetch the metadata document from <tt>https://$ISSUER/.well-known/email-verification</tt>.</t>
<t>The metadata document is JSON containing the following properties:</t>

<ul spacing="compact">
<li><em>issuance_endpoint</em> - the API endpoint the browser calls to obtain an EVT</li>
<li><em>jwks_uri</em> - the URL where the issuer provides its public keys to verify the EVT</li>
<li><em>signing_alg_values_supported</em> - OPTIONAL. JSON array containing a list of the signing algorithms ("alg" values) supported by the issuer for both HTTP Message Signatures and issued EVTs. Algorithm identifiers MUST be from the IANA "JSON Web Signature and Encryption Algorithms" registry. If omitted, "EdDSA" is the default. "EdDSA" SHOULD be included in the supported algorithms list. The value "none" MUST NOT be used.</li>
<li><em>webauthn_supported</em> - OPTIONAL. Boolean indicating whether the issuer supports WebAuthn authentication as an alternative to cookies. If <tt>true</tt>, the issuer may return a WebAuthn challenge when cookies are not present or invalid. Defaults to <tt>false</tt>.</li>
<li><em>private_email_supported</em> - OPTIONAL. Boolean indicating whether the issuer supports generating private email addresses. Defaults to <tt>false</tt>.</li>
</ul>
<t>Following is an example <tt>.well-known/email-verification</tt> file:</t>

<sourcecode type="json"><![CDATA[{
  "issuance_endpoint": "https://accounts.issuer.example/email-verification/issuance",
  "jwks_uri": "https://accounts.issuer.example/email-verification/jwks",
  "signing_alg_values_supported": ["EdDSA", "RS256"],
  "webauthn_supported": true,
  "private_email_supported": true
}
]]>
</sourcecode>
</section>
</section>

<section anchor="http-signatures"><name>HTTP Message Signatures</name>
<t>This section defines how HTTP Message Signatures (<xref target="RFC9421"/>) are used in token requests. The browser signs requests to prove possession of a key pair, and the issuer verifies these signatures.</t>

<section anchor="request-signing"><name>HTTP Request Signing</name>
<t>The browser creates a signed request by:</t>

<ol spacing="compact">
<li>Creating a JSON request body with the email address and optional parameters</li>
<li>Creating the <tt>Signature-Key</tt> header using the <tt>hwk</tt> scheme (<xref target="I-D.hardt-httpbis-signature-key"/>) with the browser's public key</li>
<li>Creating the <tt>Signature-Input</tt> header specifying the covered components</li>
<li>Computing the signature base per <xref target="RFC9421"/> Section 2.5 and signing with the browser's private key</li>
<li>Creating the <tt>Signature</tt> header with the base64-encoded signature</li>
</ol>

<section anchor="request-body"><name>Request Body</name>
<t>The request body is a JSON object with the following fields:</t>

<ul spacing="compact">
<li><tt>email</tt> (REQUIRED): The email address to verify</li>
<li><tt>private_email</tt> (OPTIONAL): Request a new private email address. See <eref target="#private-email">Private Email Addresses</eref>.</li>
<li><tt>directed_email</tt> (OPTIONAL): A previously issued private email address to reuse. See <eref target="#private-email">Private Email Addresses</eref>.</li>
</ul>
<t>Example:</t>

<sourcecode type="json"><![CDATA[{
  "email": "user@example.com"
}
]]>
</sourcecode>
</section>

<section anchor="signature-key-header"><name>Signature-Key Header</name>
<t>The <tt>Signature-Key</tt> header uses the <tt>hwk</tt> scheme to convey the browser's public key:</t>

<artwork><![CDATA[Signature-Key: sig=hwk; kty="OKP"; crv="Ed25519"; \
    x="JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"
]]>
</artwork>
</section>

<section anchor="signature-input-header"><name>Signature-Input Header</name>
<t>The covered components MUST include <tt>@method</tt>, <tt>@authority</tt>, <tt>@path</tt>, and <tt>signature-key</tt>. The <tt>cookie</tt> component MUST be included when the Cookie header is present, and MUST be omitted when it is not (per <xref target="RFC9421"/> Section 2.5). The <tt>created</tt> parameter MUST be included.</t>

<artwork><![CDATA[Signature-Input: sig=("@method" "@authority" "@path" \
    "cookie" "signature-key");created=1692345600
]]>
</artwork>
</section>

<section anchor="example-signed-request"><name>Example Signed Request</name>

<sourcecode type="http"><![CDATA[POST /email-verification/issuance HTTP/1.1
Host: accounts.issuer.example
Cookie: session=...
Content-Type: application/json
Sec-Fetch-Dest: email-verification
Signature-Input: sig=("@method" "@authority" "@path" \
    "cookie" "signature-key");created=1692345600
Signature: sig=:MEQCIHd8Y8qYKm5e3dV8y....:
Signature-Key: sig=hwk; kty="OKP"; crv="Ed25519"; \
    x="JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"

{"email":"user@example.com"}
]]>
</sourcecode>
</section>
</section>

<section anchor="request-verification"><name>HTTP Request Verification</name>
<t>The issuer MUST verify the request headers:</t>

<ul spacing="compact">
<li><tt>Content-Type</tt> is <tt>application/json</tt></li>
<li><tt>Sec-Fetch-Dest</tt> is <tt>email-verification</tt></li>
<li><tt>Signature-Input</tt> is present</li>
<li><tt>Signature</tt> is present</li>
<li><tt>Signature-Key</tt> is present with <tt>sig=hwk</tt> scheme</li>
</ul>
<t>The issuer MUST verify the HTTP Message Signature by:</t>

<ol spacing="compact">
<li>Parsing the <tt>Signature-Key</tt> header and extracting the public key from the <tt>hwk</tt> parameters (<tt>kty</tt>, <tt>crv</tt>, <tt>x</tt> for OKP keys)</li>
<li>Parsing the <tt>Signature-Input</tt> header to determine the covered components</li>
<li>Verifying that the signature covers at minimum: <tt>@method</tt>, <tt>@authority</tt>, <tt>@path</tt>, and <tt>signature-key</tt>. The signature MUST also cover <tt>cookie</tt> when the Cookie header is present.</li>
<li>Reconstructing the signature base per <xref target="RFC9421"/> Section 2.5</li>
<li>Verifying the signature in the <tt>Signature</tt> header using the extracted public key</li>
<li>Verifying the <tt>created</tt> timestamp in <tt>Signature-Input</tt> is within 60 seconds of the current time</li>
</ol>
<t>The issuer MUST verify the request body:</t>

<ol spacing="compact">
<li>Parsing the JSON body and extracting the <tt>email</tt> field</li>
<li>Verifying the <tt>email</tt> field contains a syntactically valid email address</li>
</ol>
</section>
</section>

<section anchor="evt"><name>Email Verification Token (EVT)</name>
<t>The Email Verification Token (EVT) is a JWT issued by the issuer that contains a verified email address and the browser's public key. This section defines the EVT structure and how it is created and verified.</t>

<section anchor="evt-structure"><name>EVT Structure</name>
<t>The EVT is a JWT with the following structure:</t>

<section anchor="header"><name>Header</name>

<ul spacing="compact">
<li><tt>alg</tt> (REQUIRED): Signing algorithm</li>
<li><tt>kid</tt> (REQUIRED): Key identifier of the key used to sign</li>
<li><tt>typ</tt> (REQUIRED): Set to "evt+jwt"</li>
</ul>
<t>Example:</t>

<sourcecode type="json"><![CDATA[{
  "alg": "EdDSA",
  "kid": "2024-08-19",
  "typ": "evt+jwt"
}
]]>
</sourcecode>
</section>

<section anchor="payload"><name>Payload</name>
<t>Required claims:</t>

<ul spacing="compact">
<li><tt>iss</tt>: The issuer identifier</li>
<li><tt>iat</tt>: Issued at time (seconds since epoch)</li>
<li><tt>cnf</tt>: Confirmation claim containing the browser's public key in <tt>jwk</tt> format (for SD-JWT Key Binding compatibility)</li>
<li><tt>email</tt>: The verified email address</li>
<li><tt>email_verified</tt>: Boolean, MUST be <tt>true</tt></li>
</ul>
<t>Optional claims:</t>

<ul spacing="compact">
<li><tt>is_private_email</tt>: Boolean, set to <tt>true</tt> when the email is a private address</li>
</ul>
<t>Example:</t>

<sourcecode type="json"><![CDATA[{
  "iss": "issuer.example",
  "iat": 1724083200,
  "cnf": {
    "jwk": {
      "kty": "OKP",
      "crv": "Ed25519",
      "x": "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"
    }
  },
  "email": "user@example.com",
  "email_verified": true
}
]]>
</sourcecode>
</section>

<section anchor="format"><name>Format</name>
<t>The EVT has a <tt>~</tt> appended to it for SD-JWT compatibility (see <eref target="#sd-jwt-compatibility">SD-JWT Compatibility</eref>).</t>
</section>
</section>

<section anchor="evt-creation"><name>EVT Creation</name>
<t>After verifying the request (see <eref target="#request-verification">Request Verification</eref>) and authenticating the user, the issuer creates the EVT:</t>

<ol spacing="compact">
<li>Construct the header with <tt>alg</tt>, <tt>kid</tt>, and <tt>typ</tt></li>
<li>Construct the payload with <tt>iss</tt>, <tt>iat</tt>, <tt>cnf</tt> (containing the public key from the <tt>Signature-Key</tt> header), <tt>email</tt>, and <tt>email_verified</tt></li>
<li>If a private email is requested, include <tt>is_private_email: true</tt> and set <tt>email</tt> to the private address</li>
<li>Sign the JWT with the issuer's private key corresponding to the <tt>kid</tt></li>
<li>Append <tt>~</tt> to the signed JWT</li>
</ol>
<blockquote><t>Note: The <tt>is_private_email</tt> claim name matches Apple's Sign in with Apple for compatibility with existing RP implementations.</t>
</blockquote></section>

<section anchor="evt-verification"><name>EVT Verification</name>
<t>Both the browser and RP verify the EVT. The verification steps are:</t>

<ol spacing="compact">
<li>Parse the EVT into header, payload, and signature components</li>
<li>Extract and validate the <tt>alg</tt> and <tt>kid</tt> from the header</li>
<li>Extract and validate the <tt>iss</tt>, <tt>iat</tt>, <tt>cnf</tt>, <tt>email</tt>, and <tt>email_verified</tt> claims from the payload</li>
<li>Perform <eref target="#issuer-discovery">Issuer Discovery</eref> for the email domain to verify the <tt>iss</tt> claim matches the issuer identifier</li>
<li>Fetch the issuer's public keys from the <tt>jwks_uri</tt> in the issuer metadata</li>
<li>Verify the EVT signature using the public key identified by <tt>kid</tt></li>
<li>Verify <tt>iat</tt> is within an acceptable time window</li>
<li>Verify <tt>email_verified</tt> is <tt>true</tt></li>
</ol>
<t>The browser additionally verifies:</t>

<ul spacing="compact">
<li>The <tt>email</tt> claim matches the email address being verified</li>
<li>The <tt>cnf.jwk</tt> claim matches the public key the browser generated</li>
</ul>
</section>
</section>

<section anchor="kb"><name>Key Binding (EVT+KB)</name>
<t>Key Binding ties an EVT to a specific RP and session through a Key Binding JWT (KB-JWT). The combined EVT+KB is what the RP receives and verifies.</t>

<section anchor="kb-structure"><name>KB-JWT Structure</name>
<t>The KB-JWT is a JWT with the following structure:</t>

<section anchor="header-1"><name>Header</name>

<ul spacing="compact">
<li><tt>alg</tt> (REQUIRED): Signing algorithm (same as the browser's key pair)</li>
<li><tt>typ</tt> (REQUIRED): Set to "kb+jwt" for SD-JWT library compatibility</li>
</ul>
<t>Example:</t>

<sourcecode type="json"><![CDATA[{
  "alg": "EdDSA",
  "typ": "kb+jwt"
}
]]>
</sourcecode>
</section>

<section anchor="payload-1"><name>Payload</name>

<ul spacing="compact">
<li><tt>aud</tt> (REQUIRED): The RP's origin</li>
<li><tt>nonce</tt> (REQUIRED): The nonce from the RP's session</li>
<li><tt>iat</tt> (REQUIRED): Issued at time</li>
<li><tt>sd_hash</tt> (REQUIRED): SHA-256 hash of the EVT for SD-JWT library compatibility</li>
</ul>
<t>Example:</t>

<sourcecode type="json"><![CDATA[{
  "aud": "https://rp.example",
  "nonce": "259c5eae-486d-4b0f-b666-2a5b5ce1c925",
  "iat": 1724083260,
  "sd_hash": "X9yH0Ajrdm1Oij4tWso9UzzKJvPoDxwmuEcO3XAdRC0"
}
]]>
</sourcecode>
</section>
</section>

<section anchor="evt-kb-format"><name>EVT+KB Format</name>
<t>The EVT+KB is formed by concatenating the EVT and KB-JWT separated by a tilde:</t>

<artwork><![CDATA[<EVT>~<KB-JWT>
]]>
</artwork>
<t>The EVT already has a trailing <tt>~</tt> from its SD-JWT format, so the full structure is:</t>

<artwork><![CDATA[<JWT>~<KB-JWT>
]]>
</artwork>
</section>

<section anchor="sd-jwt-compatibility"><name>SD-JWT Compatibility</name>
<t>The EVT+KB format is compatible with SD-JWT with Key Binding as specified in <xref target="I-D.ietf-oauth-selective-disclosure-jwt"/>, though this protocol does not use selective disclosure features. The following SD-JWT features are used:</t>

<ul spacing="compact">
<li><strong>Trailing <tt>~</tt> on EVT</strong>: The EVT uses the SD-JWT format (JWT with <tt>~</tt> suffix)</li>
<li><strong><tt>cnf</tt> claim</strong>: The EVT includes the <tt>cnf</tt> claim with <tt>jwk</tt> for holder key binding</li>
<li><strong><tt>typ: "kb+jwt"</tt></strong>: The KB-JWT uses the SD-JWT Key Binding JWT type</li>
<li><strong><tt>sd_hash</tt> claim</strong>: The KB-JWT includes the SD-JWT hash of the EVT</li>
<li><strong>Concatenation format</strong>: The EVT+KB uses the SD-JWT <tt>&lt;Issuer-signed-JWT&gt;~&lt;KB-JWT&gt;</tt> format</li>
</ul>
<t>Standard SD-JWT libraries can be used to parse and validate EVT+KB tokens.</t>
</section>

<section anchor="kb-creation-detail"><name>KB-JWT Creation</name>
<t>After verifying the EVT (see <eref target="#evt-verification">EVT Verification</eref>), the browser creates the KB-JWT:</t>

<ol spacing="compact">
<li>Construct the header with <tt>alg</tt> and <tt>typ</tt></li>
<li><t>Construct the payload with:</t>

<ul spacing="compact">
<li><tt>aud</tt>: The RP's origin</li>
<li><tt>nonce</tt>: The nonce from the RP's session</li>
<li><tt>iat</tt>: Current time</li>
<li><tt>sd_hash</tt>: SHA-256 hash of the EVT (including the trailing <tt>~</tt>)</li>
</ul></li>
<li>Sign the KB-JWT with the browser's private key</li>
<li>Concatenate with the EVT to form the EVT+KB</li>
</ol>
</section>

<section anchor="kb-verification"><name>KB-JWT Verification</name>
<t>The RP verifies the KB-JWT by:</t>

<ol spacing="compact">
<li>Parse the EVT+KB by separating at the tilde</li>
<li>Parse the KB-JWT into header, payload, and signature</li>
<li>Extract <tt>alg</tt> from the header and <tt>aud</tt>, <tt>nonce</tt>, <tt>iat</tt>, <tt>sd_hash</tt> from the payload</li>
<li>Verify <tt>aud</tt> matches the RP's origin</li>
<li>Verify <tt>nonce</tt> matches the nonce from the RP's session</li>
<li>Verify <tt>iat</tt> is within a reasonable time window</li>
<li>Compute the SHA-256 hash of the EVT and verify it matches <tt>sd_hash</tt></li>
<li>Verify the KB-JWT signature using the public key from the EVT's <tt>cnf.jwk</tt> claim</li>
</ol>
</section>
</section>

<section anchor="webauthn-authentication"><name>WebAuthn Authentication</name>
<t>When the issuer supports WebAuthn (<tt>webauthn_supported: true</tt> in metadata) and a token request lacks valid authentication cookies, the issuer MAY return a WebAuthn challenge to authenticate the user. This enables email verification even when the user is not logged into the issuer via cookies, using any WebAuthn-compatible credential (passkeys, security keys, platform authenticators).</t>

<section anchor="webauthn-challenge-response"><name>WebAuthn Challenge Response</name>
<t>Instead of returning an error or an EVT, the issuer returns a WebAuthn challenge. The issuer MAY include <tt>Set-Cookie</tt> headers to maintain challenge state:</t>
<t><strong>HTTP 401 Unauthorized</strong></t>

<sourcecode type="http"><![CDATA[HTTP/1.1 401 Unauthorized
Content-Type: application/json
Set-Cookie: webauthn_state=...; Secure; HttpOnly; SameSite=None; Max-Age=300

{
  "webauthn_challenge": {
    "challenge": "dGVzdC1jaGFsbGVuZ2UtZGF0YQ",
    "timeout": 60000,
    "rpId": "issuer.example",
    "allowCredentials": [
      {
        "type": "public-key",
        "id": "Y3JlZGVudGlhbC1pZA"
      }
    ],
    "userVerification": "preferred"
  }
}
]]>
</sourcecode>
<t>The <tt>webauthn_challenge</tt> object follows the structure of PublicKeyCredentialRequestOptions as defined in <xref target="WebAuthn"/>.</t>
<t>The browser MUST process any <tt>Set-Cookie</tt> headers in the response. The issuer can use cookies to maintain challenge state, enabling stateless verification of the WebAuthn response. Alternatively, the issuer MAY store challenges server-side with a short TTL.</t>
</section>

<section anchor="webauthn-response"><name>WebAuthn Response</name>
<t>After the browser obtains a WebAuthn assertion (this mechanism is being defined by the W3C (<xref target="EVP-Browser"/>)), it sends a new request to the issuance endpoint with the <tt>webauthn_response</tt>. The browser MUST include any cookies set by the challenge response:</t>

<sourcecode type="http"><![CDATA[POST /email-verification/issuance HTTP/1.1
Host: accounts.issuer.example
Cookie: webauthn_state=...
Content-Type: application/json
Sec-Fetch-Dest: email-verification
Signature-Input: sig=("@method" "@authority" "@path" "cookie" "signature-key");created=1692345600
Signature: sig=:...:
Signature-Key: sig=hwk; kty="OKP"; crv="Ed25519"; x="JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"

{
  "email": "user@example.com",
  "webauthn_response": {
    "id": "Y3JlZGVudGlhbC1pZA",
    "rawId": "Y3JlZGVudGlhbC1pZA",
    "response": {
      "authenticatorData": "...",
      "clientDataJSON": "...",
      "signature": "..."
    },
    "type": "public-key"
  }
}
]]>
</sourcecode>
<t>The <tt>webauthn_response</tt> object follows the structure of PublicKeyCredential as defined in <xref target="WebAuthn"/>.</t>
<blockquote><t>Note: The <tt>cookie</tt> component MUST be included in the signature when cookies are present (such as those set by the challenge response). If no cookies are present, the <tt>cookie</tt> component is omitted per <eref target="#request-signing">HTTP Request Signing</eref>.</t>
</blockquote></section>

<section anchor="webauthn-verification"><name>WebAuthn Verification</name>
<t>The issuer verifies the WebAuthn response against its stored credentials for the email address. If verification succeeds, the issuer returns the EVT as described in <eref target="#evt-issuance">EVT Issuance</eref>.</t>
</section>
</section>

<section anchor="private-email"><name>Private Email Addresses</name>
<t>Private email addresses allow users to provide site-specific email addresses to RPs, preventing RP-to-RP correlation of users by email address. A private email address can be:</t>

<ul spacing="compact">
<li><strong>Single-use</strong>: The browser requests a new private email and does not store it</li>
<li><strong>Reusable</strong>: The browser stores the private email and passes it back via <tt>directed_email</tt> for account continuity</li>
</ul>
<t>The choice between single-use and reusable is made by the browser or user, not the issuer. The first request to an RP always uses <tt>private_email: true</tt> to obtain a new private email address. For subsequent requests, the browser can either request another new private email or reuse an existing one by passing it in <tt>directed_email</tt>.</t>

<section anchor="request-parameters"><name>Request Parameters</name>
<t>The token request body supports one of the following parameters for private email addresses (mutually exclusive):</t>

<ul>
<li><t><tt>private_email</tt> (OPTIONAL): Boolean. When set to <tt>true</tt>, requests a new private email address instead of the user's actual email.</t>
</li>
<li><t><tt>directed_email</tt> (OPTIONAL): String. A previously issued private email address. When provided, the issuer returns the same private email address if it is valid and linked to the <tt>email</tt> in the request.</t>
</li>
</ul>
</section>

<section anchor="example-requests"><name>Example Requests</name>
<t>Request for a new private email address:</t>

<sourcecode type="json"><![CDATA[{
  "email": "user@example.com",
  "private_email": true
}
]]>
</sourcecode>
<t>Request to reuse a previously issued private email address:</t>

<sourcecode type="json"><![CDATA[{
  "email": "user@example.com",
  "directed_email": "u7x9k2m4@privaterelay.example"
}
]]>
</sourcecode>
</section>

<section anchor="requirements"><name>Requirements</name>

<ul spacing="compact">
<li>The private email MUST be a valid email address that the issuer can route to the user's actual mailbox</li>
<li>The private email SHOULD be unique per user and per RP origin (derived from the browser's context)</li>
<li>If <tt>directed_email</tt> is provided and is linked to the <tt>email</tt> address in the request, the issuer MUST return the same private email address</li>
<li>If <tt>directed_email</tt> is provided but is invalid or not linked to the <tt>email</tt>, the issuer MUST return an error</li>
<li>The private email address is included in the EVT <tt>email</tt> claim</li>
<li>The EVT MUST include <tt>is_private_email: true</tt> when a private email address is issued</li>
</ul>
</section>

<section anchor="issuer-flexibility"><name>Issuer Flexibility</name>
<t>The domain of the private email address does not need to match the domain of the user's actual email address. Additionally, the <tt>iss</tt> claim in the EVT corresponds to the issuer for the private email domain, which may differ from the issuer the browser initially contacted.</t>
<t>For example, a user with <tt>user@example.com</tt> may receive a private email address <tt>u7x9k2m4@privaterelay.different.example</tt>. The EVT's <tt>iss</tt> claim would be the issuer for <tt>privaterelay.different.example</tt>. The browser verifies the EVT by performing issuer discovery on the private email domain and validating the signature against that issuer's JWKS. This allows email providers to delegate private email functionality to a separate service. It also enables privacy for users with vanity domains (e.g., <tt>me@dickhardt.example</tt>) where the domain itself is a unique identifier that would otherwise reveal the user's identity.</t>
</section>

<section anchor="example-evt-payload"><name>Example EVT Payload</name>
<t>When a private email is issued, the EVT contains the private address in the <tt>email</tt> claim and includes <tt>is_private_email: true</tt>:</t>

<sourcecode type="json"><![CDATA[{
  "iss": "privaterelay.different.example",
  "iat": 1724083200,
  "cnf": {
    "jwk": {
      "kty": "OKP",
      "crv": "Ed25519",
      "x": "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"
    }
  },
  "email": "u7x9k2m4@privaterelay.different.example",
  "email_verified": true,
  "is_private_email": true
}
]]>
</sourcecode>
<t>The browser MAY store the private email address so it can provide it as <tt>directed_email</tt> in future requests if the user wants to reuse the same private email address at an RP. This is analogous to how browsers store usernames and passwords for sites.</t>
<t>See <eref target="#privacy-considerations">Privacy Considerations</eref> for privacy analysis of private email addresses.</t>
</section>
</section>

<section anchor="error-responses"><name>Error Responses</name>
<t>If the issuer cannot process the token request successfully, it MUST return an appropriate HTTP status code with a JSON error response containing an <tt>error</tt> field and optionally an <tt>error_description</tt> field.</t>

<section anchor="invalid-content-type-header"><name>Invalid Content-Type Header</name>
<t>When the request does not include the required <tt>Content-Type: application/json</tt> header, the server MUST return the 415 HTTP response code.</t>
</section>

<section anchor="invalid-sec-fetch-dest-header"><name>Invalid Sec-Fetch-Dest Header</name>
<t>When the request does not include the required <tt>Sec-Fetch-Dest: email-verification</tt> header:</t>
<t><strong>HTTP 400 Bad Request</strong></t>

<sourcecode type="json"><![CDATA[{
  "error": "invalid_request",
  "error_description": "Missing or invalid Sec-Fetch-Dest header"
}
]]>
</sourcecode>
<t>The <tt>error_description</tt> SHOULD specify that the Sec-Fetch-Dest header is missing or invalid.</t>
</section>

<section anchor="invalid-or-missing-http-message-signature"><name>Invalid or Missing HTTP Message Signature</name>
<t>When the HTTP Message Signature is missing, malformed, or verification fails:</t>
<t><strong>HTTP 400 Bad Request</strong></t>

<sourcecode type="json"><![CDATA[{
  "error": "invalid_signature",
  "error_description": "HTTP Message Signature verification failed"
}
]]>
</sourcecode>
<t>This includes cases where:
- The <tt>Signature</tt>, <tt>Signature-Input</tt>, or <tt>Signature-Key</tt> headers are missing
- The <tt>Signature-Key</tt> header does not use the <tt>hwk</tt> scheme or is malformed
- The signature does not cover the required components
- The signature verification fails using the public key from <tt>Signature-Key</tt>
- The <tt>created</tt> timestamp is outside the acceptable time window</t>
</section>

<section anchor="authentication-required"><name>Authentication Required</name>
<t>When the request lacks valid authentication cookies, contains expired/invalid cookies, or the authenticated user does not have control of the requested email address:</t>
<t><strong>HTTP 401 Unauthorized</strong></t>

<sourcecode type="json"><![CDATA[{
  "error": "authentication_required",
  "error_description": "User must be authenticated and have control of the requested email address"
}
]]>
</sourcecode>
</section>

<section anchor="invalid-parameters"><name>Invalid Parameters</name>
<t>When the request body is malformed, missing the <tt>email</tt> field, or contains invalid values:</t>
<t><strong>HTTP 400 Bad Request</strong></t>

<sourcecode type="json"><![CDATA[{
  "error": "invalid_request",
  "error_description": "Invalid or malformed request body"
}
]]>
</sourcecode>
</section>

<section anchor="private-email-not-supported"><name>Private Email Not Supported</name>
<t>When the request includes <tt>private_email</tt> or <tt>directed_email</tt> but the issuer does not support private email addresses (<tt>private_email_supported</tt> is <tt>false</tt> or absent in metadata):</t>
<t><strong>HTTP 400 Bad Request</strong></t>

<sourcecode type="json"><![CDATA[{
  "error": "private_email_not_supported",
  "error_description": "This issuer does not support private email addresses"
}
]]>
</sourcecode>
</section>

<section anchor="invalid-directed-email"><name>Invalid Directed Email</name>
<t>When the request includes <tt>directed_email</tt> but the private email address is invalid or not linked to the <tt>email</tt> address in the request:</t>
<t><strong>HTTP 400 Bad Request</strong></t>

<sourcecode type="json"><![CDATA[{
  "error": "invalid_directed_email",
  "error_description": "The directed_email is invalid or not linked to this email address"
}
]]>
</sourcecode>
</section>

<section anchor="server-errors"><name>Server Errors</name>
<t>For internal server errors or temporary unavailability:</t>
<t><strong>HTTP 500 Internal Server Error</strong></t>

<sourcecode type="json"><![CDATA[{
  "error": "server_error",
  "error_description": "Temporary server error, please try again later"
}
]]>
</sourcecode>
</section>
</section>

<section anchor="privacy-considerations"><name>Privacy Considerations</name>
<t>This section analyzes the privacy properties of the Email Verification Protocol, following the guidance in <xref target="RFC6973"/>.</t>

<section anchor="reduced-friction-tradeoff"><name>Reduced Friction Tradeoff</name>
<t>By reducing friction in email verification, EVP makes it easier for users to provide their email address to more sites. This convenience could accelerate the RP correlation problem—users may share a correlatable identifier with more RPs than they would if verification required more effort.</t>
<t>EVP addresses this tradeoff through private email addresses. When supported by the issuer, users can present a site-specific private email that cannot be correlated across RPs. This makes sharing a non-correlatable identifier just as easy as sharing the user's real email address, giving users a privacy-preserving option without additional friction.</t>
</section>

<section anchor="timing-correlation-by-email-providers"><name>Timing Correlation by Email Providers</name>
<t>The three-party model (see <eref target="#protocol-flow">Protocol Flow</eref>) prevents the issuer from learning which RP requested verification. When the RP uses the email only for identification and does not send emails, the email provider never learns about the RP at all. When the RP does send emails, the provider eventually learns about that RP, but only when email is actually sent—not at verification time. This dulls timing correlation.</t>
</section>

<section anchor="rp-correlation-via-email-addresses"><name>RP Correlation via Email Addresses</name>
<t>Private email addresses prevent RPs from correlating users across sites. Additional benefits:</t>
<t><strong>Protection from data breaches</strong>: If an RP suffers a data breach, only the private email is exposed—not the user's primary email address.</t>
<t><strong>Protection from unwanted email</strong>: Because the issuer controls private email routing, users can revoke or filter mail to specific addresses without affecting their primary inbox.</t>
</section>

<section anchor="issuer-knowledge"><name>Issuer Knowledge</name>
<t>The issuer learns certain information through the protocol:</t>

<ol>
<li><t><strong>Email addresses</strong>: The issuer learns that the user controls the email address in the request. This may reveal email addresses at domains the issuer is authoritative for that it did not previously know the user had.</t>
</li>
<li><t><strong>Verification requests</strong>: The issuer sees that verification was requested but does not learn which RP requested it (maintained by the three-party model).</t>
</li>
<li><t><strong>Private email mappings</strong>: When generating private emails, the issuer stores mappings between private addresses and user email addresses for mail routing.</t>
</li>
<li><t><strong>Email traffic</strong>: When RPs send email to private addresses, the issuer (operating the relay) learns about those communications.</t>
</li>
</ol>
</section>

<section anchor="rp-knowledge"><name>RP Knowledge</name>
<t>The RP can infer whether the user is logged into the issuer: the RP receives an EVT when the user is logged in, and receives an error when the user is not. This is inherent to any authentication-based verification scheme.</t>
</section>

<section anchor="browser-storage"><name>Browser Storage</name>
<t>The browser MAY store the private email address per RP origin to enable account continuity by passing it as <tt>directed_email</tt> in future requests. This is analogous to how browsers store usernames and passwords for sites.</t>
</section>
</section>

<section anchor="security-considerations"><name>Security Considerations</name>

<section anchor="http-message-signature-security"><name>HTTP Message Signature Security</name>
<t>The use of HTTP Message Signatures (<xref target="RFC9421"/>) provides several security benefits:</t>

<ol>
<li><t><strong>Request Integrity</strong>: The signature covers the HTTP method, authority, path, and cookies, preventing tampering with any of these components.</t>
</li>
<li><t><strong>Cookie Binding</strong>: By including the <tt>cookie</tt> component in the signature, the browser's authentication cookies are cryptographically bound to the specific request, preventing cookie injection or manipulation attacks.</t>
</li>
<li><t><strong>Replay Protection</strong>: The <tt>created</tt> timestamp in the <tt>Signature-Input</tt> header is verified to be within 60 seconds, preventing replay attacks.</t>
</li>
<li><t><strong>Public Key Binding</strong>: The browser's public key transmitted via the <tt>Signature-Key</tt> header with the <tt>hwk</tt> scheme is bound to the request signature, ensuring the issuer knows which public key to include in the EVT's <tt>cnf</tt> claim.</t>
</li>
</ol>
</section>

<section anchor="signature-key-hwk-scheme"><name>Signature-Key hwk Scheme</name>
<t>The <tt>hwk</tt> (Header Web Key) scheme provides:</t>

<ol>
<li><t><strong>Self-Contained Key Distribution</strong>: The public key is transmitted inline, eliminating the need for a separate key lookup or registration process.</t>
</li>
<li><t><strong>Pseudonymity</strong>: The browser does not need to identify itself - the key serves as a pseudonymous identifier for the request.</t>
</li>
<li><t><strong>Ephemeral Keys</strong>: The browser generates fresh key pairs for each verification flow, limiting the correlation potential across different verification attempts.</t>
</li>
</ol>
</section>

<section anchor="email-existence-probing"><name>Email Existence Probing</name>
<t>Any software—not just browsers—can send requests to an issuer's issuance endpoint. An attacker could attempt to use this to probe for valid email addresses:</t>

<ol spacing="compact">
<li><strong>Build email lists</strong>: Probe many addresses to identify valid ones for spam targeting.</li>
<li><strong>Account enumeration</strong>: Determine which email addresses have accounts at specific issuers.</li>
</ol>

<section anchor="uniform-error-responses"><name>Uniform Error Responses</name>
<t>To prevent probing, issuers MUST NOT return different error responses based on whether an email address exists. The <tt>authentication_required</tt> error should be returned uniformly whether:</t>

<ul spacing="compact">
<li>The email address does not exist at this issuer</li>
<li>The email address exists but the user is not authenticated</li>
<li>The email address exists but the authenticated user does not control it</li>
</ul>
<t>This ensures attackers cannot distinguish between "email exists" and "email does not exist" based on error responses.</t>
</section>

<section anchor="timing-attack-mitigations"><name>Timing Attack Mitigations</name>
<t>Response timing can also reveal whether an email address exists. If the issuer performs a database lookup only when the email exists, or takes different code paths based on email existence, an attacker can measure response times to infer information.</t>
<t>Issuers SHOULD mitigate timing attacks using techniques such as:</t>

<ul spacing="compact">
<li><strong>Uniform code paths</strong>: Execute the same operations (database lookups, cryptographic operations) regardless of whether the email exists, avoiding early returns that skip processing steps.</li>
<li><strong>Response delay normalization</strong>: Add delays to normalize response times across all error conditions to a consistent baseline.</li>
</ul>
</section>

<section anchor="additional-mitigations"><name>Additional Mitigations</name>

<ul spacing="compact">
<li><strong>User interaction required</strong>: The browser API requires user gesture and consent before initiating verification, preventing automated probing from browsers.</li>
<li><strong>Rate limiting</strong>: Issuers SHOULD rate-limit requests per IP address to slow down probing attempts from any client.</li>
<li><strong>Sec-Fetch-Dest verification</strong>: The required <tt>Sec-Fetch-Dest: email-verification</tt> header provides a signal that the request originates from a browser, though this can be spoofed by non-browser clients.</li>
<li><strong>Same information as email OTP</strong>: An attacker can already determine email existence by sending verification emails and checking for bounces. EVP does not create new information disclosure beyond what is already possible.</li>
</ul>
<t>Issuers SHOULD implement appropriate rate limiting and abuse detection.</t>
</section>
</section>
</section>

<section anchor="design-rationale"><name>Design Rationale</name>

<section anchor="why-not-solve-email-like-sms-otp"><name>Why Not Solve Email Like SMS OTP?</name>
<t>The WebOTP API and <tt>autocomplete="one-time-code"</tt> standards dramatically reduced friction for SMS verification. A natural question is why email verification cannot use the same approach. Several fundamental differences make this impractical:</t>
<t><strong>SMS is a mobile OS feature; email is application-layer</strong></t>
<t>SMS is integrated into mobile operating systems. The OS receives incoming messages and can parse them before any application sees them. This privileged position enables the OS to recognize origin-bound OTP formats and offer autofill directly to the browser.</t>
<t>Email operates at the application layer. There is no OS-level email subsystem that intercepts incoming messages. Email clients are ordinary applications—whether native apps, desktop programs, or web applications—with no special ability to coordinate with browsers for autofill.</t>
<t><strong>SMS verification is mobile; email verification spans platforms</strong></t>
<t>SMS OTP autofill works on mobile devices where the OS controls the messaging stack. Email verification happens on desktop computers, laptops, tablets, and phones. Any solution for email must work across all these platforms, not just mobile.</t>
<t><strong>SMS senders are aggregators; email senders are RPs</strong></t>
<t>SMS verification messages are typically sent through aggregator services (Twilio, AWS SNS, etc.) that send on behalf of many relying parties. The "sender" of the SMS is often a short code or phone number shared across multiple services. This means the phone number or sender ID carries little identifying information about which RP sent the message.</t>
<t>Email verification messages come directly from the RP's domain. The sender address, domain, and email headers identify the RP. This architectural difference means that email verification inherently reveals more about the RP to the email provider than SMS verification reveals to the carrier.</t>
</section>

<section anchor="why-the-three-party-model"><name>Why the Three-Party Model?</name>
<t>A simpler design would have the issuer create a token directly for the RP, with the RP as the audience. This is how social login works: the identity provider knows which application the user is logging into.</t>
<t>EVP uses a three-party model where the browser intermediates between the issuer and the RP. The issuer creates an EVT bound to the browser's ephemeral public key, and the browser creates a separate KB-JWT that binds the EVT to the RP. The issuer never learns the RP's identity.</t>
<t>This design choice is driven by privacy: for users with domain-based email accounts (personal domains, work accounts), the email provider should not learn which applications the user accesses. The architectural complexity of the three-party model is justified by this privacy benefit.</t>
</section>

<section anchor="why-sd-jwt"><name>Why SD-JWT?</name>
<t>The EVT uses the SD-JWT structure (specifically, the key binding capability from SD-JWT+KB) rather than a plain JWT. This choice provides:</t>

<ol>
<li><t><strong>Key Binding</strong>: The <tt>~</tt> separator and KB-JWT mechanism provide a standard way to bind a token to a holder's key, enabling the three-party model where issuance and presentation are separate operations.</t>
</li>
<li><t><strong>Library Support</strong>: SD-JWT libraries already exist and can parse EVTs, reducing implementation burden for RPs.</t>
</li>
<li><t><strong>Extensibility</strong>: While EVP does not currently use selective disclosure, the SD-JWT structure allows future extensions without changing the token format.</t>
</li>
</ol>
</section>

<section anchor="why-dns-delegation"><name>Why DNS Delegation?</name>
<t>The mail domain delegates email verification to an issuer via a DNS TXT record rather than a <tt>.well-known</tt> file. This choice aligns with how email infrastructure already works:</t>

<ol>
<li><t><strong>Email domains often lack web hosting</strong>: Many users have personal domains used only for email. Requiring a web server to host a <tt>.well-known</tt> file would create a barrier to adoption.</t>
</li>
<li><t><strong>Apex domain challenges</strong>: Email domains are typically apex domains (e.g., <tt>example.com</tt>), which do not support CNAME records. Hosting a web site on an apex domain requires additional infrastructure.</t>
</li>
<li><t><strong>Familiar tooling</strong>: Domain owners already manage DNS records for email (MX, SPF, DKIM, DMARC). Adding another TXT record fits existing workflows.</t>
</li>
</ol>
</section>

<section anchor="why-jwks-over-dkim-keys"><name>Why JWKS Over DKIM Keys?</name>
<t>The issuer publishes signing keys via a JWKS endpoint rather than reusing DKIM keys. While DKIM keys are already associated with email domains, JWKS provides practical advantages:</t>

<ol>
<li><t><strong>Key rotation</strong>: DKIM keys are rarely rotated in practice. JWKS rotation is common in OIDC deployments and follows established patterns.</t>
</li>
<li><t><strong>Algorithm flexibility</strong>: JWKS supports multiple key types and algorithms. DKIM key distribution was designed for a specific use case.</t>
</li>
<li><t><strong>Operational familiarity</strong>: Developers implementing EVP are likely familiar with JWKS from OAuth/OIDC work.</t>
</li>
</ol>
</section>

<section anchor="why-http-message-signatures-rather-than-request-jwt"><name>Why HTTP Message Signatures Rather Than Request JWT?</name>
<t>The original design used a JWT signed by the browser to carry the email address and browser's public key. The HTTP Message Signatures approach was chosen because:</t>

<ol spacing="compact">
<li><strong>Standards-Based</strong>: <xref target="RFC9421"/> is a published standard for signing HTTP messages, providing better interoperability</li>
<li><strong>Cookie Binding</strong>: HTTP Message Signatures can directly sign the <tt>cookie</tt> header, providing stronger binding between authentication cookies and the request</li>
<li><strong>Flexibility</strong>: The signature can cover any HTTP components, making it easier to add additional protections in the future</li>
<li><strong>Simpler Key Distribution</strong>: The Signature-Key header provides a standardized way to distribute keys inline with the request</li>
</ol>
</section>
</section>

<section anchor="implementation-status"><name>Implementation Status</name>
<t><em>Note: This section is to be removed before publishing as an RFC.</em></t>
<t>This section records the status of known implementations of the protocol defined by this specification at the time of posting of this Internet-Draft, and is based on a proposal described in <xref target="RFC7942"/>. The description of implementations in this section is intended to assist the IETF in its decision processes in progressing drafts to RFCs.</t>
<t>The following implementations are known:</t>

<ul spacing="compact">
<li><strong>Hellō</strong> — <eref target="https://hello.coop">hello.coop</eref>. Organization: Hellō. Role: Issuer. Coverage: EVT issuance endpoint, issuer discovery via DNS TXT, JWKS endpoint. Level of maturity: exploratory.</li>
</ul>
</section>

<section anchor="document-history"><name>Document History</name>
<t><em>Note: This section is to be removed before publishing as an RFC.</em></t>

<ul spacing="compact">
<li><t>draft-hardt-email-verification-00</t>

<ul spacing="compact">
<li>Initial draft.</li>
</ul></li>
</ul>
</section>

</middle>

<back>
<references><name>References</name>
<references><name>Normative References</name>
<xi:include href="https://bib.ietf.org/public/rfc/bibxml3/reference.I-D.hardt-httpbis-signature-key.xml"/>
<xi:include href="https://bib.ietf.org/public/rfc/bibxml3/reference.I-D.ietf-oauth-selective-disclosure-jwt.xml"/>
<xi:include href="https://bib.ietf.org/public/rfc/bibxml/reference.RFC.9421.xml"/>
</references>
<references><name>Informative References</name>
<reference anchor="EVP-Browser" target="https://wicg.github.io/email-verification/">
  <front>
    <title>Email Verification API</title>
    <author>
      <organization>WICG</organization>
    </author>
    <date year="2025"/>
  </front>
</reference>
<reference anchor="LightweightFedCM" target="https://github.com/fedidcg/LightweightFedCM">
  <front>
    <title>Lightweight FedCM</title>
    <author>
      <organization>FedID CG</organization>
    </author>
    <date year="2025"/>
  </front>
</reference>
<xi:include href="https://bib.ietf.org/public/rfc/bibxml/reference.RFC.6973.xml"/>
<xi:include href="https://bib.ietf.org/public/rfc/bibxml/reference.RFC.7942.xml"/>
<reference anchor="WebAuthn" target="https://www.w3.org/TR/webauthn-3/">
  <front>
    <title>Web Authentication: An API for accessing Public Key Credentials - Level 3</title>
    <author fullname="Michael B. Jones" initials="M." surname="Jones">
      <organization>Microsoft</organization>
    </author>
    <author fullname="Akshay Kumar" initials="A." surname="Kumar">
      <organization>Microsoft</organization>
    </author>
    <author fullname="Emil Lundberg" initials="E." surname="Lundberg">
      <organization>Yubico</organization>
    </author>
    <date year="2023"/>
  </front>
  <seriesInfo name="W3C" value="Recommendation"/>
</reference>
</references>
</references>

<section anchor="acknowledgments"><name>Acknowledgments</name>
<t>The authors would like to thank reviewers for their feedback on this specification.</t>
</section>

</back>

</rfc>
