가입, 로그인, 결제 또는 계정 복구 중에 이메일 주소를 수집할 때 소유권을 확인하려면 일반적으로 대역 외 일회용 비밀번호 (OTP) 또는 매직 링크를 전송해야 합니다. 이로 인해 사용자는 페이지를 떠나 이메일 전송을 기다리고 코드를 복사해야 하므로 마찰, 이탈, 피싱 위험이 발생하고 사용자가 방문하는 신뢰 당사자 (RP)가 이메일 제공업체에 노출됩니다.
이메일 인증 API (클라이언트 측 HTML 확장 프로그램) 및 이메일 인증 프로토콜 (EVP) (백엔드 암호화 프로토콜)을 사용하면 신뢰 당사자가 인증 메일을 보내지 않고도 사용자가 이메일 주소를 관리하는지 확인할 수 있습니다. 브라우저는 신뢰 당사자와 사용자의 이메일 제공업체 (발급기관) 사이에서 중개 역할을 합니다.
- 신뢰 당사자는 이메일 입력과 동일한
<form>에autocomplete="email-verification-token"및 일회용 암호화nonce가 포함된 숨겨진<input>를 포함합니다. - 사용자가 이메일 주소를 선택하거나 입력하면 브라우저는 도메인의
_email-verification.<domain>DNSTXT레코드를 조회하고, 사용자가 권한 있는 발급기관과 활성 세션을 보유하고 있는지 확인하고, 발급기관에 신뢰 당사자의 ID를 공개하지 않고 이메일 인증 토큰 (EVT)을 요청합니다. - 브라우저는 임시 브라우저 키 쌍(
EVT + KB-JWT)으로 서명된 키 결합 JWT(KB-JWT)에서 EVT를 신뢰 당사자의 출처 및 세션nonce에 결합하고, 숨겨진 입력을 채우고, 양식을 제출합니다. - 신뢰 당사자의 백엔드가 암호화 체인을 확인하고, 유효한 경우 이메일 OTP 또는 매직 링크 단계를 건너뜁니다.
1. 오리진 트라이얼 사용 설정 및 인증 양식 렌더링
확인 사이트 (신뢰 당사자)로 참여하려면 다음 단계를 따르세요.
Origin-TrialHTTP 응답 헤더 또는<meta http-equiv="origin-trial">요소를 사용하여 양식을 호스팅하는 페이지에 오리진 트라이얼 토큰을 제공합니다.- 서드 파티 오리진 트라이얼: 삽입된 ID SDK는 오리진 트라이얼 등록자 출처가 발급기관 도메인과 일치하는 경우 (발급기관과 동일한 사이트, 예:
https://issuer.example) 서드 파티 트라이얼 토큰을 삽입할 수 있습니다.
- 서드 파티 오리진 트라이얼: 삽입된 ID SDK는 오리진 트라이얼 등록자 출처가 발급기관 도메인과 일치하는 경우 (발급기관과 동일한 사이트, 예:
- 최소 128비트의 엔트로피 (예:
node:crypto의randomBytes(32).toString('base64url'))를 사용하여 양식 렌더링당 암호화된 무작위 nonce를 생성하고,HttpOnly,Secure,SameSite=Lax세션 ID 쿠키로 키가 지정된 서버 측 세션 상태에 저장하고 (공격자가 쿠키와 토큰을 모두 재생할 수 있는 클라이언트 쿠키 내에 예상nonce값을 직접 저장하지 않음), 숨겨진 토큰 입력의nonce속성으로 렌더링합니다. 뒤로/앞으로 탐색에서 이미 사용된 nonce를 재사용하지 않도록Cache-Control: no-store를 사용하여 페이지를 제공합니다. - 이메일 입력(
autocomplete="email")과 동일한<form>내에 숨겨진 토큰 입력(autocomplete="email-verification-token")을 추가합니다.
<!-- 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를 지원하지 않는 브라우저 또는 사용자가 이메일 제공업체에서 로그아웃한 경우) 인증에 실패하면 즉시 표준 이메일 OTP 또는 매직 링크 흐름으로 대체합니다.
// 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);
}
커스텀 JWT 또는 SD-JWT 파싱 코드를 직접 작성하는 대신 백엔드 플랫폼에 대해 확립된 SD-JWT 및 JOSE/JWT 라이브러리 (예: Node.js의 @sd-jwt/core 및 jose, Python의 sd-jwt-python / jwcrypto, JVM의 sd-jwt-java / Nimbus JOSE+JWT)를 사용하여 verifyEmailVerificationToken를 구현하세요.
1단계: SD-JWT 클레임 파싱 및 이메일 주소 검증
서버 측 세션 저장소에서 일회용 expectedNonce를 사용한 후 SD-JWT 프레젠테이션 (EVT + KB-JWT)을 디코딩하고 다음을 확인합니다.
- 발급자 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단계: DNS TXT 위임 검증 및 발급기관 JWKS 로드
iss에 네트워크 요청을 하기 전에 제출된 이메일의 도메인이 _email-verification.<domain> DNS TXT 레코드 (iss=<issuer-host> -> https://<issuer-host>, 포트, 경로, 후행 슬래시 없음)를 통해 해당 발급기관에 권한을 위임하는지 확인합니다. DNS 위임이 iss과 바이트 단위로 일치하면 HTTPS를 통해 ${iss}/.well-known/email-verification를 가져오고 metadata.issuer === iss를 확인한 후 발급기관의 JSON 웹 키 세트 (JWKS)를 로드합니다.
kid처리 관련 참고사항: 일부 주요 이메일 제공업체 (예: Gmail)는 EVT 헤더에서kid를 생략합니다.kid가 없고 JWKS에 여러 키가 포함된 경우jose는ERR_JWKS_MULTIPLE_MATCHING_KEYS(일치하는CryptoKey의AsyncIterable)를 발생시킵니다. 3단계에 표시된 대로for await (const publicKey of err)를 반복하여 각 키를 시험적으로 확인합니다.
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에 대해 확인하고(kid가 생략된 경우ERR_JWKS_MULTIPLE_MATCHING_KEYS를 포착하여 후보 키를 반복)iss및 엄격한 최신성 기간 (maxTokenAge: '5m',clockTolerance: '1m')을 적용합니다. - 소지자 결합 (
kbVerifier및sdJwt.verify옵션):evtPayload.cnf.jwk에서 브라우저의 임시 공개 키를 추출하고,cnf.jwk.alg및decoded.kbJwt.header.alg가 모두ALLOWED_ALGS에 있고 일치하는지 확인하고 (오리진 트라이얼 구현이 RFC 9864'Ed25519'로 전환되는 동안'Ed25519'및'EdDSA'를 동일하게 처리),typ: 'kb+jwt'로KB-JWT서명을 확인합니다.keyBindingNonce: expectedNonce,expectedKeyBindingAudience: expectedAudience,keyBindingMaxAgeSeconds: 300를sdJwt.verify()에 전달합니다.@sd-jwt/core는kbVerifier만 호출하고keyBindingNonce가 제공된 경우nonce,aud,iat,sd_hash를 검증합니다.
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 라이브러리 (예: Node.js의
@sd-jwt/core및jose)를 사용하여EVT~KB-JWT프레젠테이션을 파싱하고 검증하며,keyBindingNonce를sdJwt.verify()에 전달하여 키 결합 검증을 우회할 수 없도록 합니다. 커스텀 JWT, SD-JWT 또는sd_hash파싱 및 서명 확인 루틴을 직접 작성하지 마세요. - 각 양식 렌더링에 대해 서버에서 새로운 암호화
nonce(최소 128비트, 예:randomBytes(32).toString('base64url'))를 생성하고, 이를 서버 측 세션 상태 (클라이언트 쿠키에 직접 저장하지 않음)에 저장하고,Cache-Control: no-store를 사용하여 양식 페이지를 제공해야 합니다. POST요청이 도착하면 서버 측 세션 스토리지에서 예상되는nonce를 즉시 원자적으로 읽고 삭제하여 가로챈 토큰을 다시 재생할 수 없도록 해야 합니다.evtPayload.email와 제출된 양식 이메일 주소 (email.toLowerCase()) 간에 대소문자를 구분하지 않는 비교를 수행하면서 제출된 이메일 문자열을 보존합니다.- EVT 헤더에
kid가 없는 경우 발급기관의 JWKS에 있는 모든 키를 시험 검증해야 합니다(Gmail과 같은 제공업체는kid를 생략함). - EVT의
iss클레임을 신뢰하기 전에 DNS 위임 (_email-verification.<domain>)을 확인하고.well-known/email-verification응답에서metadata.issuer === iss를 확인해야 합니다. - 이메일 소유권 확인을 받은편지함 전송 가능 여부의 증거로 간주하지 마세요. EVP는 사용자가 권한 있는 제공업체의 이메일 계정을 관리한다는 것을 증명합니다. 거래 관련 환영 이메일을 계속 전송하고 반송 메일을 정상적으로 처리합니다.
token가 비어 있거나 인증에 실패한 경우 계정 생성 또는 로그인을 차단하지 마세요. 항상 표준 이메일 OTP 또는 매직 링크 흐름으로 단계적으로 전환하세요.
대체 전략
이메일 인증 (autocomplete="email-verification-token")은 기존의 대역 외 이메일 인증 (OTP 코드 또는 매직 링크)을 점진적으로 개선하도록 설계되었습니다.
- 지원되지 않는 브라우저:
autocomplete="email-verification-token"를 지원하지 않는 브라우저는 숨겨진 입력을 무시하고 빈token값으로 양식을 제출합니다. - 인증되지 않거나 참여하지 않는 이메일 도메인: 사용자가 도메인에서
_email-verificationDNSTXT레코드를 게시하지 않는 이메일 주소를 입력하거나 사용자가 브라우저에서 이메일 제공업체와 활성 세션을 보유하지 않는 경우 브라우저는 빈token로 양식을 제출합니다. - 만료되거나 잘못된 토큰:
nonce가 만료되었거나, 이미 사용되었거나,handleSignupSubmission의verifyEmailVerificationToken단계를 실패하면 서버가 오류를 포착하고 사용자의 가입 또는 로그인 흐름을 중단하지 않고 기존 OTP 또는 매직 링크 이메일을 전송합니다.