E-posta adresi sahipliğini doğrulama

Kayıt, oturum açma, ödeme veya hesap kurtarma sırasında e-posta adresi toplarken sahipliği doğrulamak için genellikle bant dışı tek kullanımlık şifre (OTP) veya sihirli bağlantı gönderilmesi gerekir. Bu durum, kullanıcıları sayfanızdan ayrılmaya, e-postanın teslim edilmesini beklemeye ve kodları kopyalamaya zorlar. Bu da sürtüşmeye, ayrılmaya ve kimlik avı riskine yol açarken kullanıcının hangi bağlı tarafı (RP) ziyaret ettiğini e-posta sağlayıcıya gösterir.

E-posta Doğrulama API'si (istemci taraflı HTML uzantısı) ve E-posta Doğrulama Protokolü (EVP) (arka uç kriptografik protokolü), bağlı tarafın doğrulama e-postası göndermeden bir kullanıcının e-posta adresini kontrol ettiğini doğrulamasını sağlar. Tarayıcı, güvenen taraf ile kullanıcının e-posta sağlayıcısı (veren) arasında aracılık yapar:

  1. Bağlı taraf, e-posta girişiyle aynı <form> içinde autocomplete="email-verification-token" içeren gizli bir <input> ve tek kullanımlık kriptografik nonce içerir.
  2. Kullanıcı bir e-posta adresi seçtiğinde veya girdiğinde tarayıcı, alanın _email-verification.<domain> DNS TXT kaydını arar, kullanıcının yetkili verenle etkin bir oturumu olduğunu doğrular ve güvenen tarafın kimliğini verene ifşa etmeden bir E-posta Doğrulama Jetonu (EVT) ister.
  3. Tarayıcı, EVT'yi geçici bir tarayıcı anahtar çifti (EVT + KB-JWT) ile imzalanmış bir Anahtar Bağlama JWT'sinde (KB-JWT), güvenen tarafın kaynağına ve oturumuna nonce bağlar, gizli girişi doldurur ve formu gönderir.
  4. Güvenen tarafın arka ucu, şifreleme zincirini doğrular ve geçerliyse e-posta OTP'sini veya sihirli bağlantı adımını atlar.

1. Kaynak Denemesini Etkinleştirme ve Doğrulama Formunu Oluşturma

Doğrulama sitesi (güvenen taraf) olarak katılmak için:

  1. Formu barındıran sayfada Origin-Trial HTTP yanıt başlığını veya <meta http-equiv="origin-trial"> öğesini kullanarak kaynak denemesi jetonunu sağlayın.
    • Üçüncü taraf kaynak denemeleri: Yerleşik kimlik SDK'ları, kaynak denemesi tescil ettiren kaynağın, veren alan adıyla eşleşmesi (verenle aynı site, örneğin https://issuer.example) koşuluyla üçüncü taraf deneme jetonu ekleyebilir.
  2. En az 128 bit entropi içeren (ör. node:crypto'ten randomBytes(32).toString('base64url')) form oluşturma başına kriptografik olarak rastgele bir tek seferlik rastgele sayı oluşturun, bunu HttpOnly, Secure, SameSite=Lax oturum kimliği çereziyle anahtarlanmış sunucu tarafı oturum durumunda depolayın (beklenen nonce değerini hiçbir zaman doğrudan bir istemci çerezinde depolamayın. Saldırgan hem çerezi hem de jetonu tekrar oynatabilir) ve gizli jeton girişinin nonce özelliğinde oluşturun. Geri/ileri gezinmenin daha önce kullanılmış bir tek seferlik rastgele sayıyı yeniden kullanmaması için sayfayı Cache-Control: no-store ile sunun.
  3. Gizli jeton girişini (autocomplete="email-verification-token"), e-posta girişiyle (autocomplete="email") aynı <form> içine ekleyin.
<!-- 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. Gönderilen jetonu sunucuda doğrulama

Form gönderildiğinde token alanını güvenilmeyen giriş olarak değerlendirin. Beklenen nonce değerini, alındığında sunucu tarafı oturum deponuzdan atomik olarak okuyup silin. token alanı boşsa (ör. EVP'yi desteklemeyen tarayıcılarda veya kullanıcı e-posta sağlayıcısında oturumunu kapattığında) ya da doğrulama başarısız olursa hemen standart e-posta OTP'nize veya sihirli bağlantı akışınıza geri dönün:

// 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 uygulamasını kullanmak için özel JWT veya SD-JWT ayrıştırma kodunu manuel olarak oluşturmak yerine her zaman arka uç platformunuz için oluşturulmuş SD-JWT ve JOSE/JWT kitaplıklarını (ör. Node.js'de @sd-jwt/core ve jose, Python'da sd-jwt-python/jwcrypto veya JVM'de sd-jwt-java/Nimbus JOSE+JWT) kullanın.

Step 1: Parse the SD-JWT claims and validate the email address

Sunucu tarafı oturum depolama alanından tek kullanımlık expectedNonce kullanıldıktan sonra SD-JWT sunumunun (EVT + KB-JWT) kodunu çözün ve şunları doğrulayın:

  • Hem veren JWT (jwt) hem de anahtar bağlama JWT (kbJwt) ve veren hak talebi (iss) mevcut.
  • evtPayload.email_verified === true.
  • evtPayload.email, büyük/küçük harfe duyarlı olmayan bir karşılaştırma (toLowerCase()) kullanarak gönderilen form e-postasıyla eşleşir.
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. adım: DNS TXT yetkilendirmesini doğrulayın ve veren kuruluşun JWKS'sini yükleyin

iss adresine herhangi bir ağ isteği göndermeden önce, gönderilen e-postanın alan adının, bir _email-verification.<domain> DNS TXT kaydı (bağlantı noktası, yol veya sondaki eğik çizgi olmadan iss=<issuer-host> -> https://<issuer-host>) aracılığıyla tam olarak bu veren kuruluşa yetki verdiğini doğrulayın. DNS yetkilendirmesi iss ile bayt bazında eşleştiğinde HTTPS üzerinden ${iss}/.well-known/email-verification değerini getirin, metadata.issuer === iss değerini onaylayın ve verenin JSON Web Anahtarı Kümesi'ni (JWKS) yükleyin.

kid işleme ile ilgili not: Bazı büyük e-posta sağlayıcıları (ör. Gmail), EVT üstbilgisinden kid değerini çıkarır. kid yoksa ve JWKS birden fazla anahtar içeriyorsa jose, ERR_JWKS_MULTIPLE_MATCHING_KEYS (eşleşen CryptoKey'lerin AsyncIterable) hatası verir. 3. adımda gösterildiği gibi her anahtarı deneme amaçlı doğrulamak için for await (const publicKey of err) üzerinde iterasyon yapın.

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. adım: EVT imzasını ve anahtar bağlama JWT'sini doğrulayın (KB-JWT)

Son olarak, açık asimetrik algoritma izin verilenler listesini (['Ed25519', 'EdDSA', 'ES256']) kullanarak her iki imzayı ve iki jeton arasındaki kriptografik bağlamayı doğrulayın:

  1. EVT imzası (verifier): issuerJwks ile doğrulayın (kid atlandığında aday anahtarları yinelemek için ERR_JWKS_MULTIPLE_MATCHING_KEYS yakalanır), iss ve sıkı bir yenilik aralığı (maxTokenAge: '5m', clockTolerance: '1m') zorunlu kılınır.
  2. Kart sahibi bağlama (kbVerifier ve sdJwt.verify seçenekleri): Tarayıcının geçici ortak anahtarını evtPayload.cnf.jwk öğesinden çıkarın, hem cnf.jwk.alg hem de decoded.kbJwt.header.alg öğelerinin ALLOWED_ALGS içinde olduğunu ve eşleştiğini doğrulayın (Kaynak Denemesi uygulamaları RFC 9864 'Ed25519''e geçerken 'Ed25519' ve 'EdDSA' öğelerini eşdeğer olarak kabul edin) ve KB-JWT imzasını typ: 'kb+jwt' ile doğrulayın. keyBindingNonce: expectedNonce, expectedKeyBindingAudience: expectedAudience ve keyBindingMaxAgeSeconds: 300 değerlerini sdJwt.verify() öğesine iletin. @sd-jwt/core, yalnızca keyBindingNonce sağlandığında kbVerifier öğesini çağırır ve nonce, aud, iat ve sd_hash değerlerini doğrular.
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,
});

En iyi uygulamalar

  • ÖNERİLİR: Tüm jeton doğrulama işlemlerini kesinlikle sunucuda gerçekleştirin. İstemci taraflı JavaScript'te onay anahtarlarını DOĞRULAMAYIN veya istemci taraflı doğrulama durumlarına güvenmeyin. İstemci taraflı kontroller kolayca atlanabilir veya sahtesi yapılabilir ve güvenen tarafa sıfır güven garantisi sağlar.
  • MUTLAKA EVT~KB-JWT sunumunu ayrıştırmak ve doğrulamak için arka uç platformunuzda yerleşik SD-JWT ve JOSE/JWT kitaplıklarını (ör. Node.js'de @sd-jwt/core ve jose) kullanın ve keyBindingNonce değerini sdJwt.verify() değerine iletin. Böylece anahtar bağlama doğrulaması atlanamaz. Özel JWT, SD-JWT veya sd_hash ayrıştırma ve imza doğrulama rutinlerini manuel olarak oluşturmayın.
  • Her form oluşturma işlemi için sunucuda yeni bir şifreleme nonce (en az 128 bit, örneğin randomBytes(32).toString('base64url')) OLUŞTURUN, bunu sunucu tarafı oturum durumunda (doğrudan istemci çerezinde değil) saklayın ve form sayfasını Cache-Control: no-store ile sunun.
  • POST isteği geldiğinde, beklenen nonce değerini sunucu tarafı oturum depolama alanından atomik olarak okuyup hemen SİLİN. Böylece, ele geçirilen jetonlar yeniden kullanılamaz.
  • evtPayload.email ile gönderilen form e-posta adresi (email.toLowerCase()) arasında büyük/küçük harfe duyarlı olmayan bir karşılaştırma YAPIN. Bu sırada gönderilen e-posta dizesini koruyun.
  • kid, EVT üstbilgisinde yoksa (Gmail gibi sağlayıcılar kid'yi atlar) veren kuruluşun JWKS'sindeki tüm anahtarları deneme amaçlı olarak DO doğrulayın.
  • EVT'deki iss hak talebine güvenmeden önce DNS yetkilendirmesini (_email-verification.<domain>) DOĞRULAYIN ve .well-known/email-verification yanıtında metadata.issuer === iss değerini onaylayın.
  • E-posta sahipliği doğrulamasını, gelen kutusuna teslim edilebilirlik kanıtı olarak KULLANMAYIN. EVP, kullanıcının e-posta hesabını yetkili sağlayıcıyla kontrol ettiğini kanıtlar. İşlemle ilgili karşılama e-postaları göndermeye ve geri dönen e-postaları normal şekilde işlemeye devam edin.
  • token boş olduğunda veya doğrulama başarısız olduğunda hesap oluşturma ya da oturum açma işlemlerini ENGELLEMEYİN. Her zaman standart e-posta OTP'nize veya sihirli bağlantı akışınıza sorunsuz bir şekilde geçiş yapın.

Yedek strateji

E-posta Doğrulaması (autocomplete="email-verification-token"), geleneksel bant dışı e-posta doğrulama (OTP kodları veya sihirli bağlantılar) yöntemlerine kıyasla progresif geliştirme olarak tasarlanmıştır:

  • Desteklenmeyen tarayıcılar: autocomplete="email-verification-token" özelliğini desteklemeyen tarayıcılar, gizli girişi yoksayar ve formu boş bir token değeriyle gönderir.
  • Kimliği doğrulanmamış veya katılmayan e-posta alanları: Kullanıcı, alanı _email-verification DNS TXT kaydı yayınlamayan bir e-posta adresi girdiğinde veya kullanıcının tarayıcıda e-posta sağlayıcısıyla etkin bir oturumu olmadığında tarayıcı, formu boş bir token ile gönderir.
  • Süresi dolmuş veya geçersiz jetonlar: nonce süresi dolmuşsa, daha önce kullanılmışsa veya handleSignupSubmission içindeki verifyEmailVerificationToken adımlarından herhangi birinde başarısız olursa sunucu hatayı yakalar ve kullanıcının kaydolma veya oturum açma akışını kesintiye uğratmadan geleneksel bir OTP ya da sihirli bağlantı e-postası gönderir.