При сборе адресов электронной почты во время регистрации, входа, оформления заказа или восстановления доступа к аккаунту для подтверждения права собственности обычно требуется отправить одноразовый код доступа или ссылку для входа. Пользователям приходится покидать страницу, ждать письма и копировать код. Это неудобно, повышает риск фишинга и приводит к тому, что пользователи уходят с сайта. Кроме того, поставщик услуг электронной почты узнает, какой сайт посещает пользователь.
Email Verification API (клиентское расширение HTML) и Email Verification Protocol (EVP) (внутренний криптографический протокол) позволяют проверяющей стороне убедиться, что пользователь управляет адресом электронной почты, не отправляя письмо с подтверждением. Браузер выступает в качестве посредника между полагающейся стороной и поставщиком услуг электронной почты пользователя (эмитентом):
- Агент включает скрытый элемент
<input>с атрибутомautocomplete="email-verification-token"и одноразовый криптографический элементnonceв тот же элемент<form>, что и поле ввода адреса электронной почты. - Когда пользователь выбирает или вводит адрес электронной почты, браузер ищет запись
_email-verification.<domain>DNSTXTдомена, проверяет, есть ли у пользователя активный сеанс активности с уполномоченным эмитентом, и запрашивает токен подтверждения адреса электронной почты (EVT), не сообщая эмитенту личность полагающейся стороны. - Браузер связывает токен EVT с источником и сеансом полагающейся стороны
nonceв токене JWT с привязкой ключа (KB-JWT), подписанном эфемерной парой ключей браузера (EVT + KB-JWT), заполняет скрытое поле ввода и отправляет форму. - Бэкенд полагающейся стороны проверяет криптографическую цепочку и, если она действительна, пропускает этап с одноразовым кодом или ссылкой для входа.
1. Как включить эксперимент с источником и показать форму подтверждения
Чтобы участвовать в качестве проверяющего сайта (доверяющей стороны):
- Укажите токен эксперимента с источником на странице, где размещена форма, используя заголовок HTTP-ответа
Origin-Trialили элемент<meta http-equiv="origin-trial">.- Эксперименты со сторонними источниками. Встроенные SDK идентификации могут внедрять сторонний токен эксперимента, если источник зарегистрированного пользователя эксперимента с источником совпадает с доменом издателя (находится на том же сайте, что и издатель, например
https://issuer.example).
- Эксперименты со сторонними источниками. Встроенные SDK идентификации могут внедрять сторонний токен эксперимента, если источник зарегистрированного пользователя эксперимента с источником совпадает с доменом издателя (находится на том же сайте, что и издатель, например
- Генерируйте криптографически случайный одноразовый код для каждого показа формы с энтропией не менее 128 бит (например,
randomBytes(32).toString('base64url')изnode:crypto), сохраняйте его в состоянии сеанса на стороне сервера с ключом в виде файла cookie идентификатора сеансаHttpOnly,Secure,SameSite=Lax(никогда не сохраняйте ожидаемое значениеnonceнепосредственно в файле cookie клиента, где злоумышленник может воспроизвести и файл cookie, и токен) и отображайте его в атрибутеnonceскрытого ввода токена. Добавьте на страницу заголовокCache-Control: no-store, чтобы при переходе назад или вперед не использовался уже задействованный одноразовый код. - Добавьте скрытый ввод токена (
autocomplete="email-verification-token") в тот же элемент<form>, что и ввод адреса электронной почты (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. Проверка отправленного токена на сервере
При отправке формы считайте поле token ненадежным вводом. Атомарно считывайте и удаляйте ожидаемый nonce из хранилища сеансов на стороне сервера при получении. Если поле token пустое (например, в браузерах, которые не поддерживают EVP, или когда пользователь вышел из аккаунта поставщика услуг электронной почты) или если проверка не удалась, немедленно вернитесь к стандартному процессу входа с помощью одноразового кода или ссылки:
// 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);
}
Чтобы реализовать verifyEmailVerificationToken, всегда используйте для своей серверной платформы проверенные библиотеки SD-JWT и JOSE/JWT (например, @sd-jwt/core и jose в Node.js, sd-jwt-python/jwcrypto в Python или sd-jwt-java/Nimbus JOSE+JWT на JVM), а не пишите собственный код для синтаксического анализа JWT или SD-JWT.
Шаг 1. Проанализируйте утверждения SD-JWT и подтвердите адрес электронной почты
После использования одноразового значения expectedNonce из хранилища сеансов на стороне сервера декодируйте представление SD-JWT (EVT + KB-JWT) и убедитесь, что:
- Присутствуют оба токена JWT: издателя (
jwt) и привязки ключа (kbJwt), а также утверждение издателя (iss). evtPayload.email_verified === true.evtPayload.emailсовпадает с адресом электронной почты, указанным в форме, без учета регистра (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.');
}
Шаг 2. Проверьте делегирование TXT-записи DNS и загрузите JWKS издателя
Прежде чем отправлять сетевой запрос на адрес iss, убедитесь, что домен указанного адреса электронной почты делегирует полномочия именно этому издателю через запись _email-verification.<domain> DNS TXT (iss=<issuer-host> -> https://<issuer-host> без порта, пути или косой черты в конце). Когда делегирование DNS будет побайтово совпадать с iss, получите ${iss}/.well-known/email-verification по HTTPS, подтвердите, что metadata.issuer === iss, и загрузите набор веб-ключей JSON (JWKS) эмитента.
Примечание об обработке
kid. Некоторые крупные поставщики услуг электронной почты (например, Gmail) опускаютkidв заголовке EVT. Еслиkidотсутствует, а JWKS содержит несколько ключей,joseвыдаетERR_JWKS_MULTIPLE_MATCHING_KEYS(AsyncIterableсовпадающихCryptoKey). Повторитеfor await (const publicKey of err), чтобы проверить каждый ключ, как показано на шаге 3.
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));
Шаг 3. Проверьте подпись EVT и JWT привязки ключа (KB-JWT)
Наконец, проверьте обе подписи и криптографическую связь между двумя токенами, используя явный белый список асимметричных алгоритмов (['Ed25519', 'EdDSA', 'ES256']):
- Подпись EVT (
verifier). Проверьте ее с помощьюissuerJwks(перехватываяERR_JWKS_MULTIPLE_MATCHING_KEYS, чтобы перебрать ключи-кандидаты, еслиkidне указан), обеспечив соблюдениеissи узкого окна актуальности (maxTokenAge: '5m',clockTolerance: '1m'). - Привязка держателя (варианты
kbVerifierиsdJwt.verify). Извлеките временный открытый ключ браузера изevtPayload.cnf.jwk, убедитесь, чтоcnf.jwk.algиdecoded.kbJwt.header.algнаходятся вALLOWED_ALGSи совпадают (считайте'Ed25519'и'EdDSA'эквивалентными, пока реализации эксперимента с источником переходят на RFC 9864'Ed25519'), и проверьте подписьKB-JWTс помощьюtyp: 'kb+jwt'. ПередайтеkeyBindingNonce: expectedNonce,expectedKeyBindingAudience: expectedAudienceиkeyBindingMaxAgeSeconds: 300вsdJwt.verify().@sd-jwt/coreвызываетkbVerifierи проверяетnonce,aud,iatиsd_hash, только если указаноkeyBindingNonce.
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,
});
Рекомендации
- РЕКОМЕНДУЕТСЯ выполнять проверку токенов только на сервере. НЕ проверяйте токены в коде JavaScript на стороне клиента и не доверяйте статусу проверки на стороне клиента. Проверки на стороне клиента можно легко обойти или подделать, и они не дают никаких гарантий доверия полагающейся стороне.
- ИСПОЛЬЗУЙТЕ стандартные библиотеки SD-JWT и JOSE/JWT для своей серверной платформы (например,
@sd-jwt/coreиjoseв Node.js), чтобы анализировать и проверять представлениеEVT~KB-JWT, а также передаватьkeyBindingNonceвsdJwt.verify(). Это позволит избежать обхода проверки привязки ключа. НЕ создавайте собственные процедуры синтаксического анализа и проверки подписи JWT, SD-JWT илиsd_hash. - РЕКОМЕНДУЕТСЯ генерировать на сервере новый криптографический
nonce(не менее 128 бит, напримерrandomBytes(32).toString('base64url')) для каждого рендеринга формы, сохранять его в состоянии сеанса на стороне сервера (не непосредственно в файле cookie клиента) и обслуживать страницу формы с помощьюCache-Control: no-store. - РЕКОМЕНДУЕТСЯ атомарно считывать и удалять ожидаемый параметр
nonceиз хранилища сеансов на стороне сервера сразу после поступления запросаPOST, чтобы перехваченный токен нельзя было использовать повторно. - РЕКОМЕНДУЕТСЯ выполнять сравнение без учета регистра между
evtPayload.emailи адресом электронной почты, указанным в форме (email.toLowerCase()), сохраняя при этом строку с адресом электронной почты. - РЕКОМЕНДУЕТСЯ проверять все ключи в JWKS издателя, если в заголовке EVT отсутствует
kid(некоторые поставщики, например Gmail, не указываютkid). - РЕКОМЕНДУЕТСЯ проверять делегирование DNS (
_email-verification.<domain>), прежде чем доверять утверждениюissв EVT, и подтверждатьmetadata.issuer === issв ответе.well-known/email-verification. - НЕ считайте подтверждение права собственности на адрес электронной почты доказательством того, что письма доставляются в папку "Входящие". EVP подтверждает, что пользователь управляет аккаунтом электронной почты у авторитетного поставщика. При этом по-прежнему отправляются транзакционные приветственные письма и обрабатываются возвраты.
- НЕ блокируйте создание аккаунта или вход в него, если
tokenпуст или проверка не удалась. Всегда используйте стандартный одноразовый код, отправленный по электронной почте, или ссылку для входа.
Резервная стратегия
Подтверждение адреса электронной почты (autocomplete="email-verification-token") – это прогрессивное улучшение традиционного подтверждения адреса электронной почты вне диапазона (одноразовые коды или ссылки для входа).
- Неподдерживаемые браузеры. Браузеры, которые не поддерживают
autocomplete="email-verification-token", игнорируют скрытые входные данные и отправляют форму с пустым значениемtoken. - Неаутентифицированные или не участвующие домены электронной почты. Если пользователь вводит адрес электронной почты, домен которого не публикует запись
_email-verificationDNSTXT, или если у пользователя нет активного сеанса с поставщиком услуг электронной почты в браузере, браузер отправляет форму с пустым полемtoken. - Просроченные или недействительные токены. Если срок действия
nonceистек, он уже был использован или не прошел какой-либо этапverifyEmailVerificationTokenвhandleSignupSubmission, сервер обнаруживает ошибку и отправляет традиционный одноразовый код или ссылку для входа по электронной почте, не прерывая процесс регистрации или входа пользователя.