# engineering

Verifying an email address without the round-trip

We implemented Chrome's Email Verification Protocol: the registration email still goes out, but some signups are verified without anyone clicking the link in it. Why we want it standardised, what we verify, and how to opt in.

The old email round-trip — mail a link, find it, click it — struck through, with a green arc leaping straight from the sign-up form to “verified”, labelled autocomplete="email-verification-token".

Confirming an email address has worked the same way for about twenty years: we mail you a link, you go find it, you click it, you come back. For a small share of Short.io signups, that round-trip no longer happens — the browser hands us a signed proof along with the form.

Why we bothered

We want onboarding to be as short as we can make it, and email verification is the step that resists that hardest. We also can’t drop it: a link shortener with unverified signups drowns in phishing and spam almost immediately, and the confirmation email is a large part of what keeps that under control.

So the step stays, but nothing about it is convenient. “Leave this page, open your email client, find our message, come back” is the worst moment in the flow and the exact opposite of what the rest of signup is trying to do. Chrome’s Email Verification Protocol (EVP) is the first proposal we’ve seen that removes the interruption without removing the check — which is why we’d rather help it become a standard than wait and see whether it does.

Signing up without the round-trip

EVP is in origin trial. Instead of mailing a link and waiting, the browser collects a signed assertion that the user controls the address, and hands it over with the form.

It defines four roles, and two of them are easy to run together by mistake. The mailbox provider owns the email domain. The issuer is the service that manages the account and signs assertions about it, which is not necessarily the same host: for Gmail the mailbox domain is gmail.com, and it delegates by DNS to an issuer at accounts.google.com. The holder is Chrome on the user’s device, and the verifier is the site collecting the address — the only role we implement.

That delegation is the whole trust model in one hop. We look up the mailbox domain, the domain names its issuer, and the issuer’s keys are the only thing we’ll accept a signature from. Today _email-verification.gmail.com answers iss=accounts.google.com, and that issuer’s metadata points its key set at a third host, verifiablecredentials-pa.googleapis.com.

On our side the signup form grows a hidden input marked autocomplete="email-verification-token", carrying a nonce we minted server-side in its nonce attribute. If the provider participates, Chrome fills that input’s value on submit and we verify it.

The email field itself also has to be marked autocomplete="email", so the browser knows which value the token is about. Ours said autocomplete="username" — a reasonable thing for a credential-ish field, and what a password manager wants to see — and with that in place EVP never engaged at all. Chrome doesn’t report why it skipped a field, so check this first rather than fifth.

All of it is progressive enhancement: if anything doesn’t cooperate, we fall back to the email-link flow that was already there. A failed verification is never an HTTP error and never blocks a signup.

What we actually verify

Chrome fills the field with an SD-JWT with key binding — a ~-delimited concatenation:

<issuer-signed EVT JWT>~<disclosure>~…~<disclosure>~<key-binding JWT>

The nonce is single-use, consumed with an atomic get-and-delete before any signature work, so two concurrent submissions of the same token can’t both get past it. Then comes issuer discovery, which is unusual in that nothing is preconfigured: a DNS TXT record at _email-verification.<mailbox-domain> names the issuer host, /.well-known/email-verification on that host gives a jwks_uri, and the token is verified against the key set found there.

Five discovery steps: the submitted address gives the mailbox domain; a DNS TXT record at _email-verification.gmail.com names iss=accounts.google.com; that host's /.well-known/email-verification gives a jwks_uri; the key set is fetched from verifiablecredentials-pa.googleapis.com; the token is verified against those keys. Steps three and four are marked as going through the untrusted-URL proxy.

Discovery is also where EVP hands you an SSRF hole, so harden it before you ship. The issuer host arrives in a DNS TXT record, which means anyone who signs up with a domain they control gets to choose a URL our server will fetch. We got lucky on timing: we already run a small proxy service dedicated to fetching untrusted URLs, built for link previews, so issuer metadata and key-set fetches go through it instead of a direct fetch. It range-checks the host at connect time rather than before — which is what closes the DNS-rebinding window — refuses redirects, and caps the response size, with egress network policy as the backstop underneath.

Two of the remaining checks are about not trusting the token’s own claims. The issuer is derived from the mailbox domain of the address that was submitted, never from an iss claim inside the token — otherwise a token could nominate whoever it liked as its own authority. And the audience is checked against origins from our own configuration, never against a request header, because headers are attacker-controlled.

The subtle one is selective disclosure. An SD-JWT needn’t put every claim in the signed payload. It can sign only a digest of a claim and send the claim itself alongside, as one of those ~-delimited pieces that the holder appends at presentation time. The verifier is meant to hash each piece it receives and confirm the result appears in the issuer’s signed digest list. Skip that and merge the pieces as they arrive, and a holder can append their own [salt, "email", "[email protected]"] to overwrite the address the issuer actually signed. Chrome sends email as a direct claim today, so nothing exercises this — which is exactly why it would sit there quietly until some provider changed shape.

The token as four tilde-separated segments: an issuer-signed EVT, two holder-appended disclosures, and a key-binding JWT. Arrows from the disclosures point into a highlighted check: hash every disclosure you receive, and confirm the result appears in the digest list the issuer signed.

sd_hash is easy to mistake for the defence here, and isn’t one. What it does is bind the key-binding JWT to this exact SD-JWT and this exact set of selected disclosures, so nobody can add, drop or alter a piece after the holder signed — the presentation is sealed. What it can’t tell you is whether the issuer ever authorised any given disclosure, because it’s computed over whatever the holder chose to present. Sealing the envelope and vouching for the contents are different jobs; only the digest check does the second one.

Where it got interesting

A feature designed to fail quietly failed completely, and nothing noticed. Google’s EVP key set publishes five Ed25519 keys, none carrying a kid — and EVT headers don’t carry one either. Our JWT library matched all five, could not choose between them, and threw JWKSMultipleMatchingKeys, which a catch around signature verification flattened into “invalid signature”. Every genuine Chrome token was rejected. Signups kept working, users kept getting confirmation emails, no error rate moved.

The fix walks the candidate keys the error carries, swallowing only genuine signature mismatches so the loop can’t become an accept-anything oracle. Against a real captured token, candidate #4 matched. The tests hadn’t caught it because our test double handed the verifier a single bare key, so key selection — the thing that broke — was never exercised at all.

Trying it yourself

We’ve enabled EVP for 0.5% of devices, which is a more honest way to put it than 0.5% of signups. There’s no feature-flag infrastructure in the dashboard and signup is pre-auth, so there’s no user id to bucket on; the gate is a client-side hash of PostHog’s anonymous device id. Because that hash is deterministic, a given device is always on the same side of it — so a device either has EVP on every visit or never sees it at all, rather than each signup attempt rolling the dice.

To put yourself in the bucket regardless, run this in the console on the site and reload the signup page:

localStorage.setItem("evp_force", "1")

The flag alone won’t show you anything, though, and this is where most of our own false alarms came from. Four things have to be true at once:

  • Chrome, since the origin trial runs there through November 2026.
  • Signed in to a participating provider in that same Chrome profile — today that means being logged into the Google Account for the Gmail address you’re testing with. A different profile, or a signed-out one, gets you nothing.
  • The address picked from the autofill dropdown, not typed. This is the one that catches people. Chrome only issues a token for an address the user selected from autofill or autocomplete; typing the same address by hand produces no token at all, and support for manually typed addresses is listed as future work. So a hand-typed signup falling back to the email link is EVP working as specified, not EVP broken.
  • A fresh signup, since the flow is pre-auth and there’s nothing to trigger from an existing account’s settings.

One more, learned the hard way: an installed 1Password extension stops the flow working. Our reading is that it takes over the email field’s dropdown, so the address gets filled by 1Password rather than selected from Chrome’s own autofill — and Chrome’s autofill selection is the thing that triggers issuance. Disable it for the origin, or test in a clean profile. It fails exactly like a broken rollout, which cost us more time than it should have.

If it works you’ll see nothing at all: no extra step, no new screen, the account already verified. The registration email still arrives, because we send it before verification comes back — you just never need to click it, and its token has already been cleared. Sending a message we’ve decided you don’t need is the obvious next thing to fix.

The general lesson isn’t about the cryptography; that part is specified. It’s that we built something whose entire purpose is to fail invisibly, then shipped it with no way to tell “failing invisibly” apart from “not running at all”.