Verifying an Email Address Without Sending One
See how browser email verification proves mailbox control without sending a message, with form setup, a five-step Node.js token verifier, and trial limitations.
The Email Verification Protocol lets a site confirm that a user controls an email address without sending an email: the browser gets a signed token from the user’s email provider and submits it with the form.
Most signup flows still say “check your inbox,” and some of the people who switch tabs to find the code never come back. Chrome is testing a replacement. The Chrome origin trial announced on 8 July 2026 began in Chrome 150, and Chrome published an August 2026 update afterwards. The protocol itself is defined in the IETF Internet-Draft draft-hardt-email-verification.
This is not SMTP or MX list cleaning. Those tools check whether a mailbox exists. This protocol proves that the person filling in the form is signed in to that mailbox. The sections below cover the three-party model, the form markup, a complete Node.js verifier split into five steps, and how far you can rely on it during the trial.
Key Takeaways
- The Email Verification Protocol returns an SD-JWT with key binding: an Email Verification Token signed by the issuer, then a tilde, then a key-binding JWT signed by the browser.
- The browser sits between your site and the email provider, so the provider issues the token without learning which site asked for it.
- A valid token proves an active session with the email provider for that address. It does not prove that mail sent to the address will be delivered.
- The feature is a proposal in a Chrome origin trial that has traffic limits and no end date in Chrome’s posts. In their IETF 126 slides, the draft’s authors (not Chrome) estimated three to six months. Chrome’s documentation names only Gmail as a participating provider.
- If the hidden token field arrives empty, or any verification step fails, send your usual confirmation email.
What Does Browser Email Verification Replace?
Browser email verification replaces the one-time code or magic-link email that sends users away from your site. The IETF draft walks through what the code flow asks of a user: leave for the mail app, wait for delivery, maybe dig through spam, copy the code, then return and paste it. The draft adds that plenty of people drop out partway. A magic link saves the copy-and-paste step, but the user still has to go to the inbox and wait for the message.
Social login skips the email altogether. The draft notes what that costs: the user has to hold an account with that provider, and you have to build one integration per provider. The Email Verification Protocol uses the same principle (the provider vouches for the address) but works through the browser, with no provider-specific SDK on your site.
How the Email Verification Protocol Works: Three Parties
The Email Verification Protocol involves three parties. Your site is the verifier (the relying party). The browser sits between you and the issuer, which is the service that runs the user’s email account. Because every exchange passes through the browser, the issuer never finds out which site asked for the token.
Verifier (your site) <--> Browser <--> Issuer (e.g. accounts.google.com)
nonce, validation binds token checks session,
to your origin signs EVT
The email provider and the issuer can be different hosts. In Chrome’s example, the provider is gmail.com and the issuer is accounts.google.com.
On the issuer side, the email domain publishes a DNS TXT record that delegates to the issuer. The issuer serves /.well-known/email-verification metadata, reports login state to the browser through the Login Status API, and, before signing anything, checks the user’s existing first-party session cookies (see Secure Cookies and HttpOnly for how those cookies should be configured). The draft also lets the browser request per-site private addresses, which the EVT marks with is_private_email: true. Chrome’s trial documentation does not describe private addresses. Earlier revisions of the draft let an issuer fall back to a WebAuthn challenge when no valid cookie was present, but revision -02 removed that option, so a request without a session now simply fails.
The Site Side: Form Markup and Nonce
On your side, the form needs two inputs: a normal email field with autocomplete="email", and a hidden field with autocomplete="email-verification-token" that carries a nonce bound to the user’s session. When the form is submitted, the browser fills the hidden field with the token. This is the markup from Chrome’s post, with the nonce rendered by the server:
<input
name="email-address"
type="email"
autocomplete="email">
<input
type="hidden"
name="token"
nonce="rAnD0m-VaLuE"
autocomplete="email-verification-token">
The draft’s session-binding rules call for a random nonce carrying at least 128 bits of entropy. Generate a fresh one for every verification request and let it expire after a short time. Store the render time next to it, because step 2 of the verifier needs it:
import { randomBytes } from "node:crypto";
const nonce = randomBytes(32).toString("base64url"); // 256 bits
req.session.evp = { nonce, renderedAt: Date.now() };
The page also needs your origin trial token. Chrome’s origin trials documentation explains how to supply it. Per the August update, third-party origin trials are not supported for this feature.
Verifying the Token on the Server
Server-side verification of an Email Verification Protocol token takes five steps: parse it, check the expected values, check the key binding, check DNS delegation, and verify the issuer’s signature. The form value is <EVT>~<KB-JWT>. The EVT is the issuer-signed token and holds email, email_verified, iss, iat and a cnf.jwk browser key. The KB-JWT is signed by the browser and holds aud, nonce, iat and sd_hash. Do not trust any decoded claim until every step below has passed. The code uses jose and Node built-ins. Each block runs inside a try block in one verifyEmailToken() function that returns false if anything throws.
These are the imports the five steps need:
import { createHash } from "node:crypto";
import { resolveTxt } from "node:dns/promises";
import {
createRemoteJWKSet,
decodeJwt,
decodeProtectedHeader,
importJWK,
jwtVerify,
} from "jose";
1. Parse. Split the value at the tilde and check the typ headers.
const [evt, kb, ...rest] = token.split("~");
if (!evt || !kb || rest.length) return false;
const evtHeader = decodeProtectedHeader(evt);
const kbHeader = decodeProtectedHeader(kb);
if (evtHeader.typ !== "evt+jwt" || kbHeader.typ !== "kb+jwt") return false;
const evtClaims = decodeJwt(evt);
const kbClaims = decodeJwt(kb);
Today’s tokens carry no selective disclosures, so the value splits cleanly into two parts. The August update calls this shortcut fragile, because it would break if disclosures were ever added. Chrome recommends parsing the token with an SD-JWT library such as @sd-jwt/core, which its own demo now uses.
2. Expected values. Chrome’s validation steps check email_verified, email, nonce, aud, and an iat that falls after the form was rendered. Compare the email case-insensitively: the August update warns that providers may send back the address with different capitalisation from what the user entered.
const { nonce, renderedAt } = req.session.evp ?? {};
delete req.session.evp; // single use
if (evtClaims.email_verified !== true) return false;
if (String(evtClaims.email).toLowerCase() !== submittedEmail.toLowerCase()) return false;
if (!nonce || kbClaims.nonce !== nonce) return false;
if (kbClaims.aud !== "https://example.com") return false;
if (kbClaims.iat < Math.floor(renderedAt / 1000)) return false;
// Any maximum age is your own policy; the sources define none.
3. Key binding. sd_hash is the SHA-256 of the EVT including its trailing ~. The KB-JWT must also verify against the key in cnf.jwk.
const hash = createHash("sha256").update(evt + "~").digest("base64url");
if (hash !== kbClaims.sd_hash) return false;
const holderKey = await importJWK(evtClaims.cnf.jwk, kbHeader.alg);
await jwtVerify(kb, holderKey, { typ: "kb+jwt" });
4. DNS delegation. Query _email-verification.<domain>. The draft requires exactly one TXT record, and its value must begin with iss=. Use dig +short TXT _email-verification.gmail.com to check a domain by hand.
const domain = submittedEmail.split("@").pop().toLowerCase();
const records = (await resolveTxt(`_email-verification.${domain}`))
.map((chunks) => chunks.join(""));
if (records.length !== 1 || !records[0].startsWith("iss=")) return false;
const issuer = records[0].slice(4);
5. Discovery and EVT signature. Fetch the issuer metadata, then verify the EVT against its jwks_uri. The draft defines the issuer identifier as https:// followed by the host from the DNS record, and the EVT’s iss claim must match it exactly.
const meta = await (await fetch(`https://${issuer}/.well-known/email-verification`)).json();
const jwks = createRemoteJWKSet(new URL(meta.jwks_uri));
const { payload } = await jwtVerify(evt, jwks, {
algorithms: meta.signing_alg_values_supported,
typ: "evt+jwt",
}); // written so it does not depend on a kid header
// The draft's issuer identifier is "https://" plus the DNS host, compared byte for byte.
if (payload.iss !== `https://${issuer}`) return false;
return true;
| Check | Artifact | On failure |
|---|---|---|
| Parse | ~ split, typ headers | Send confirmation email |
| Expected values | email_verified, email, nonce, aud, iat | Send confirmation email |
| Key binding | sd_hash, KB-JWT signature vs cnf.jwk | Send confirmation email |
| DNS | single iss= TXT record | Send confirmation email |
| Issuer signature | metadata, jwks_uri, iss | Send confirmation email |
The Node DNS module returns each TXT record as an array of chunks, which is why step 4 joins them.
What Are the Limits of Browser-Based Email Verification?
A successful Email Verification Protocol check shows that the user is currently signed in to their email provider with that address. It says nothing about whether mail you send there will arrive. Chrome and the draft both make this point, so keep sending welcome emails.
The browser profile the user fills the form in must already hold a signed-in session with the provider, and the first time an address is verified the user sees a permission prompt, once per address. In the July release the address had to come from autofill. According to the August update, typing or pasting the address now triggers verification when the field loses focus. The draft also points out a small privacy leak: whether a token arrives tells your site if the user is signed in to the issuer.
What to Do Now
The Email Verification Protocol is not ready to replace your confirmation email yet. The browser API is incubating at WICG, and the protocol is an individual Internet-Draft (revision -02, August 2026). Chrome caps traffic in the trial so that sites don’t build on the feature before it launches. Chrome’s posts give no end date. The estimate of three to six months comes from the draft’s authors, in their IETF 126 DISPATCH slides, not from Chrome. Chrome also warns of backwards-incompatible changes, and the August update says the feature is desktop-only up to Chrome 152, with Android support still being explored. Chrome’s documentation names only Gmail as a participating provider. The draft also lists Hellō as an implementing issuer and mentions a Microsoft Edge origin trial.
Issuance has already changed once. The July post described a form-encoded request_token JWT. The draft replaces it with HTTP Message Signatures (RFC 9421) and marks the old format as deprecated. From Chrome 153, Chrome sends application/json with Signature, Signature-Input and Signature-Key headers, and a body that holds only the email address. This is an issuer-side change, and your verifier code is not affected.
Keep your OTP or magic-link flow as the default path. If you join the trial, treat the token as progressive enhancement: when it verifies, skip the email. After verification, create your session the way you normally do (see Cookies vs localStorage for JWT Auth). The EVT+KB is a one-time proof and should not be used as a session token.
The protocol replaces “go check your inbox” with a signature check you run on your own server, and the verifier needs only five steps. Add the hidden input and the nonce, deploy verifyEmailToken() behind your existing confirmation email, and read the Chrome update post for changes before each Chrome release.
FAQs
How is the Email Verification Protocol different from Sign in with Google?
Sign in with Google is a per-provider login integration that creates an identity relationship between your site and Google. The Email Verification Protocol only confirms that the user controls an email address. It needs no provider SDK on your site, and because the browser mediates the exchange, the issuer signs the token without learning which site requested it.
Can I use crypto.randomUUID() to generate the nonce?
No. A version 4 UUID contains only 122 random bits, because the version and variant fields take up the remaining 6 bits. The IETF draft requires a nonce with at least 128 bits of entropy. Use randomBytes(32).toString('base64url') from node:crypto instead, which gives 256 bits. Store the nonce in the server session and delete it after one use.
Does the Email Verification Protocol work for addresses on custom domains or providers other than Gmail?
Only if the email domain publishes exactly one _email-verification TXT record, starting with iss=, that delegates to an issuer supporting the protocol. Chrome's documentation names Gmail as the participating email provider. For any other domain without that record, the browser cannot obtain a token, so your verifier should treat it as a failed check and send the normal confirmation email.