Mert Özen Her Satırda Daha İleri
Güvenlik Authentication ve Authorization — #07

Passkey'ler 2: ASP.NET Core Identity 10 ile Passkey Kaydı ve Passwordless Giriş, Angular İstemciyle

Mert Özen 8 Kas 2026 10 dk 5 görüntülenme
Passkey'ler 2: ASP.NET Core Identity 10 ile Passkey Kaydı ve Passwordless Giriş, Angular İstemciyle

ASP.NET Core Identity 10, SignInManager'ın MakePasskeyCreationOptionsAsync, PerformPasskeyAttestationAsync ve PasskeySignInAsync method'larıyla passkey kaydını ve passwordless girişi hazır getiriyor; ama ServerDomain boşsa RP ID Host header'ından gelir ve passkey girişi amr olarak pwd yazar. Angular istemciyi, conditional UI'ı ve headless Chrome'un sanal authenticator'ıyla uçtan uca testi kuruyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10.0.13 runtime), Microsoft.AspNetCore.Identity.EntityFrameworkCore 10.0.14, Angular 22.2 ve Chrome 156 (headless, sanal authenticator) ile derlenip denenmiştir.

Önce Tahmin Et

Bir önceki yazıda passkey'in phishing'e dayanıklı, kendi başına çok faktörlü bir yöntem olduğunu gördük. Bookstore'da Ada parolasız, yalnız passkey'iyle giriş yapıyor. Ardından /me endpoint'i oturumun amr claim'ini döndürüyor. Ne yazıyor: mfa, hwk, swk?

sign out        -> signed-out
GET /me         -> 401
passkey login   -> signed-in (passkey)
GET /me         -> {"name":"ada@bookstore.test","amr":["pwd"]}

pwd. ASP.NET Core Identity 10'un PasskeySignInAsync'i oturumu parola girişiyle aynı yoldan açıyor ve aynı değeri yazıyor. 5. yazıdaki gibi amr == "mfa"'ya bakan bir step-up policy'si, passkey ile giren kullanıcıyı parola ile girmiş gibi görür. Bu, Identity'nin passkey desteğinin bilinçli sınırlarından biri ve bu yazıda hepsini tek tek göreceğiz. Önce Identity 10'un passkey API (Application Programming Interface)'leriyle kayıt ve passwordless girişi kuruyoruz; Identity'nin hazır passkey arayüzü yalnız Blazor şablonunda olduğu için Angular tarafını kendimiz yazıyoruz. En sonda da her şeyi headless Chrome'un sanal authenticator'ıyla uçtan uca çalıştırıyoruz.

IdentityPasskeyOptions ve ServerDomain

Passkey desteği, 3. yazıda gördüğümüz IdentitySchemaVersions.Version3 ile gelen AspNetUserPasskeys tablosuna ve IdentityPasskeyOptions'a dayanıyor. Varsayılanlar:

AuthenticatorTimeout=00:05:00 ChallengeSize=32 ServerDomain=(null)
UserVerificationRequirement=required ResidentKeyRequirement=preferred AttestationConveyancePreference=(null)

ServerDomain RP (Relying Party) ID'dir ve varsayılanı null: Identity bu durumda RP ID'yi request'in Host header'ından çıkarıyor. Microsoft dokümanı bunu açıkça bir güvenlik notu olarak yazıyor; lab'da ne anlama geldiğini gördüm. ServerDomain boşken, giriş yapmış bir oturumla ve sahte bir Host header'ıyla creation options istedim:

$ curl -s -X POST http://localhost:19170/account/passkeys/creation-options -H "Host: evil.example" -H "Cookie: bookstore.auth=..."
{"rp":{"name":"evil.example","id":"evil.example"},"user":{"id":"NDQ3ZjY0NmItZmI0Mi00YWFiLTk3NjYtMTZlMjcwYzk3ZTBk","name"...

RP ID artık saldırganın seçtiği alan adı. Arkada Host header'ını doğrulamayan bir reverse proxy varsa ya da uygulama birden çok host adına cevap veriyorsa bu, credential'ın yanlış bir alan adı için kapsanmasına yol açar. Bookstore'da RP ID'yi configuration'dan veriyoruz:

builder.Services.Configure<IdentityPasskeyOptions>(options =>
{
    // RP ID: asla Host header'ından çıkarılmaz
    options.ServerDomain = builder.Configuration["Passkeys:ServerDomain"];
    options.UserVerificationRequirement = "required";
    options.ResidentKeyRequirement = "required";
    options.AuthenticatorTimeout = TimeSpan.FromMinutes(2);
});

Aynı istek ServerDomain=localhost ile {"rp":{"name":"localhost","id":"localhost"}... döndü. Ayrıca AllowedHosts ile Kestrel'in kabul ettiği host adlarını daraltmak da iyi bir ikinci katman. ResidentKeyRequirement'ı required yaptım, çünkü kullanıcı adı sormadan giriş için credential'ın discoverable olması gerekiyor. UserVerificationRequirement zaten required; bunu düşürmek passkey'i tek factor'e indirir.

Kayıt Endpoint'leri

Kayıt, kullanıcı giriş yapmışken iki istekle olur. İlki creation options'ı üretir:

passkeys.MapPost("/creation-options", async (HttpContext context, UserManager<BookstoreUser> users, SignInManager<BookstoreUser> signIn) =>
{
    BookstoreUser user = (await users.GetUserAsync(context.User))!;
    string optionsJson = await signIn.MakePasskeyCreationOptionsAsync(new()
    {
        Id = await users.GetUserIdAsync(user),
        Name = (await users.GetUserNameAsync(user))!,
        DisplayName = (await users.GetUserNameAsync(user))!
    });
    await CaptureAsync("creation-options.json", optionsJson);
    return TypedResults.Content(optionsJson, "application/json");
}).RequireAuthorization();

CaptureAsync lab'a özel; ham WebAuthn (Web Authentication) verisini bir önceki yazıda çözmek için dosyaya yazıyor. Asıl önemli olan görünmeyen kısım: MakePasskeyCreationOptionsAsync challenge'ı ve kaydın hangi kullanıcıya ait olduğunu (attestation state) bir yere koymak zorunda. Cevapta bunu gördüm:

HTTP/1.1 200 OK
Set-Cookie: Identity.TwoFactorUserId=CfDJ8EINq_wm4TFCv7guzy6SHCezncBJP83CF5H7tWjrQKM5XgBJnnplkwo-mMxGCuA2ux3OUDNCvSjm87S...

SignInManager, state'i 2FA (Two-Factor Authentication) akışının Identity.TwoFactorUserId scheme'ine, Data Protection ile korunan bir cookie'ye yazıyor ve okuduğu anda siliyor. Dokümanın vurguladığı üç gereksinim böylece karşılanıyor: istemci state'i okuyamaz ve değiştiremez (bütünlük), state bu tarayıcı oturumuna bağlıdır (sahiplik) ve bir kez kullanılır. IPasskeyHandler<TUser>'ı doğrudan çağırırsan bu sorumluluk sana geçer; state'i bir hidden field'a ya da request body'ye koymak, saldırganın kendi authenticator'ını başka bir hesaba eklemesine kapı açar. Bu yüzden SignInManager method'larını kullan.

İkinci istek tarayıcının ürettiği credential'ı doğrular ve saklar:

passkeys.MapPost("/", async (PasskeyCredentialRequest request, HttpContext context, UserManager<BookstoreUser> users, SignInManager<BookstoreUser> signIn) =>
{
    BookstoreUser user = (await users.GetUserAsync(context.User))!;
    if ((await users.GetPasskeysAsync(user)).Count >= 10)
    {
        return Results.BadRequest(new[] { "TooManyPasskeys" });
    }

    await CaptureAsync("attestation.json", request.CredentialJson);
    PasskeyAttestationResult attestation = await signIn.PerformPasskeyAttestationAsync(request.CredentialJson);
    if (!attestation.Succeeded)
    {
        return Results.BadRequest(new[] { attestation.Failure.Message });
    }

    attestation.Passkey.Name = request.Name is { Length: > 0 and <= 50 } name ? name : "Passkey";
    IdentityResult saved = await users.AddOrUpdatePasskeyAsync(user, attestation.Passkey);
    return saved.Succeeded ? TypedResults.NoContent() : Results.BadRequest(saved.Errors.Select(e => e.Code));
}).RequireAuthorization();

Kullanıcı başına passkey sayısı ve isim uzunluğu sınırı, dokümanın "resource limits" başlığındaki öneri; yoksa biri hesabına binlerce passkey ekleyip tabloyu şişirebilir. PerformPasskeyAttestationAsync type, challenge, origin, RP ID hash'i, bayraklar ve public key'i kontrol ediyor; attestation statement'ı ise varsayılan olarak doğrulamıyor.

Giriş Endpoint'leri ve Replay

passkeys.MapPost("/request-options", async (PasskeyRequestOptionsRequest request, UserManager<BookstoreUser> users, SignInManager<BookstoreUser> signIn) =>
{
    // E-posta yoksa tarayıcı discoverable credential'ları sunar (conditional UI)
    BookstoreUser? user = request.Email is null ? null : await users.FindByEmailAsync(request.Email);
    string optionsJson = await signIn.MakePasskeyRequestOptionsAsync(user);
    await CaptureAsync("request-options.json", optionsJson);
    return TypedResults.Content(optionsJson, "application/json");
});

passkeys.MapPost("/sign-in", async (PasskeyCredentialRequest request, SignInManager<BookstoreUser> signIn) =>
{
    await CaptureAsync("assertion.json", request.CredentialJson);
    try
    {
        SignInResult result = await signIn.PasskeySignInAsync(request.CredentialJson);
        return result.Succeeded ? TypedResults.NoContent() : Results.Unauthorized();
    }
    catch (InvalidOperationException)
    {
        // Bu tarayıcıda request options üretilmemiş (state yok ya da süresi dolmuş)
        return Results.BadRequest(new[] { "NoPasskeyCeremonyUnderway" });
    }
});

try/catch lab'ın ilk sürümünde yoktu. Yakalanmış bir assertion'ı state cookie'si olmadan gönderince endpoint 500 döndü ve log'da şu vardı: System.InvalidOperationException: No passkey assertion is underway. Make sure to call 'SignInManager.MakePasskeyRequestOptionsAsync()' to initiate a passkey assertion. Bu bir programlama hatası değil, kullanıcı tarafında olağan bir durum (sekme uzun süre açık kaldı, cookie silindi), bu yüzden 400'e çeviriyorum. Replay denemesinin iki hâli:

== replay without state
["NoPasskeyCeremonyUnderway"] 400
== replay with fresh request-options
HTTP/1.1 401 Unauthorized

Taze bir challenge alınsa bile eski assertion'daki challenge farklı olduğu için giriş reddediliyor. request-options'a e-posta verilirse Identity allowCredentials'a o kullanıcının credential id'lerini koyar; bu, bir e-postanın passkey'li bir hesaba ait olup olmadığını sızdırır. Bookstore'da varsayılan akış e-postasız, discoverable credential'larla.

Angular: PasskeyService

WebAuthn Level 3'ün JSON (JavaScript Object Notation) yardımcıları sayesinde Base64Url dönüşümlerini elle yazmak gerekmiyor. Sunucunun ürettiği JSON parseCreationOptionsFromJSON ile tarayıcının beklediği yapıya dönüşüyor, credential da JSON.stringify ile (credential'ın toJSON method'u üzerinden) geri gidiyor:

@Injectable({ providedIn: 'root' })
export class PasskeyService {
  private readonly http = inject(HttpClient);

  async register(name: string): Promise<void> {
    const optionsJson = await firstValueFrom(
      this.http.post<PublicKeyCredentialCreationOptionsJSON>('/account/passkeys/creation-options', {}),
    );
    const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
    const credential = await navigator.credentials.create({ publicKey });
    if (!(credential instanceof PublicKeyCredential)) {
      throw new Error('No passkey was created');
    }
    await firstValueFrom(
      this.http.post('/account/passkeys', { credentialJson: JSON.stringify(credential), name }),
    );
  }

  async signIn(email: string | null, mediation?: CredentialMediationRequirement, signal?: AbortSignal): Promise<void> {
    const optionsJson = await firstValueFrom(
      this.http.post<PublicKeyCredentialRequestOptionsJSON>('/account/passkeys/request-options', { email }),
    );
    const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);
    const credential = await navigator.credentials.get({ publicKey, mediation, signal });
    if (!(credential instanceof PublicKeyCredential)) {
      throw new Error('No passkey was selected');
    }
    await firstValueFrom(
      this.http.post('/account/passkeys/sign-in', { credentialJson: JSON.stringify(credential) }),
    );
  }

  async conditionalUiAvailable(): Promise<boolean> {
    return typeof PublicKeyCredential !== 'undefined'
      && typeof PublicKeyCredential.isConditionalMediationAvailable === 'function'
      && await PublicKeyCredential.isConditionalMediationAvailable();
  }
}

Tipler (PublicKeyCredentialCreationOptionsJSON ve diğerleri) TypeScript 6'nın DOM (Document Object Model) tanımlarında var; ek bir paket gerekmedi. Microsoft dokümanı bazı parola yöneticilerinin toJSON'u hatalı implement ettiğini ve JSON.stringify'ın "Illegal invocation" hatası verebildiğini not ediyor; bu hatayı görürsen credential'ı alan alan elle serileştiren bir yedek yol ekle.

Bir de cookie tarafı: creation-options endpoint'i TypedResults.Content döndüğü için .NET 10'un "API endpoint'ine redirect etme" kuralı onu kapsamadı ve giriş yapmamış bir istek 302 aldı. fetch redirect'i takip edip HTML (HyperText Markup Language) getirince Angular'da anlamsız bir JSON hatası görürsün. Cookie event'lerini override ettim:

builder.Services.ConfigureApplicationCookie(options =>
{
    options.Cookie.Name = "bookstore.auth";
    // Angular istemcisi bu endpoint'leri fetch ile çağırıyor; login sayfasına redirect anlamsız
    options.Events.OnRedirectToLogin = context =>
    {
        context.Response.StatusCode = StatusCodes.Status401Unauthorized;
        return Task.CompletedTask;
    };
    options.Events.OnRedirectToAccessDenied = context =>
    {
        context.Response.StatusCode = StatusCodes.Status403Forbidden;
        return Task.CompletedTask;
    };
});
unauthenticated creation-options: 401

Conditional UI: Passkey'i Autofill'de Göstermek

Conditional UI (User Interface), passkey'lerin kullanıcı adı alanının autofill listesinde görünmesidir. Kullanıcı ayrı bir "passkey ile giriş" düğmesi aramaz; e-posta alanına tıklayınca kayıtlı passkey'i görür. İki şart var: input'un autocomplete değerinde webauthn geçmeli ve sayfa açılır açılmaz mediation: 'conditional' ile bir get() başlatılmalı:

  async ngOnInit(): Promise<void> {
    if (await this.passkeys.conditionalUiAvailable()) {
      // conditional UI: passkey'ler kullanıcı adı alanının autofill listesinde görünür
      this.passkeys.signIn(null, 'conditional', this.conditional.signal)
        .then(() => this.status.set('signed-in (autofill)'))
        .catch(() => { /* iptal edildi ya da kapatıldı: form kullanılabilir kalır */ });
    }
  }

  async passkeyLogin(email: string | null): Promise<void> {
    // conditional istek beklerken modal bir istek çalışamaz
    this.conditional.abort();
    await this.run(() => this.passkeys.signIn(email), 'signed-in (passkey)');
  }

Template'teki input: <input id="email" type="email" autocomplete="username webauthn" />. Conditional istek arka planda bekler; kullanıcı düğmeye basarsa önce onu AbortController ile iptal etmek gerekir, çünkü tarayıcı aynı anda iki WebAuthn isteğine izin vermez. Component yok edilirken de iptal ediyorum (DestroyRef), yoksa başka bir sayfaya geçince bekleyen istek askıda kalır.

Uçtan Uca: Headless Chrome ve Sanal Authenticator

Passkey akışını elle tıklayarak test etmek hem yavaş hem tekrarlanamaz. Chrome DevTools Protocol'ün WebAuthn domain'i gerçek bir cihaz yerine yazılımsal bir authenticator ekliyor. Angular build'ini lab API'sinin wwwroot'una koyup headless Chrome'u bir Node script'iyle sürdüm:

await send('WebAuthn.enable', { enableUI: false });
const auth = await send('WebAuthn.addVirtualAuthenticator', {
  options: { protocol: 'ctap2', ctap2Version: 'ctap2_1', transport: 'internal', hasResidentKey: true,
    hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true },
});

Script gerçek Angular component'inin düğmelerine basıyor ve ekrandaki durumu okuyor:

password login  -> signed-in (password)
add passkey     -> passkey added
GET passkeys    -> [{"id":"BFfTOg+PXeLmG3URY+oL9VZk/VycIQ44bB3aYXFyviM=","name":"Lab virtual authenticator","createdAt":"2026-11-08T06:07:50.370046+00:00","signCount":1,"isBackedUp":false,"isBackupEligible":false,"isUserVerified":true,"transports":["internal"]}]
sign out        -> signed-out
GET /me         -> 401
passkey login   -> signed-in (passkey)
GET /me         -> {"name":"ada@bookstore.test","amr":["pwd"]}
authenticator   -> [{"rpId":"localhost","isResidentCredential":true,"signCount":2}]
origin          -> http://127.0.0.1:19170
passkey login   -> error: SecurityError This is an invalid domain.

Parolayla giriş, passkey ekleme, çıkış, e-posta yazmadan passkey ile giriş ve başka bir origin'den deneme. Sanal authenticator device-bound olduğu için isBackupEligible false. Aynı script CI (Continuous Integration) pipeline'ında da koşabilir. Conditional UI'ın autofill seçimini bu yolla test edemedim: headless Chrome autofill listesini göstermiyor; o kısım için gerçek bir tarayıcıda elle deneme gerekiyor.

Identity'nin Sınırları

Microsoft dokümanı Identity'nin passkey desteğini "genel amaçlı bir WebAuthn library'si değil, Identity'nin authentication senaryolarına odaklı" diye tanımlıyor. Pratikte şu sınırlar var. Passkey 2FA'nın ikinci adımı olarak kullanılamıyor; birincil giriş yöntemi ve PasskeySignInAsync iki adımlı doğrulamayı atlıyor (kaynak kodda bypassTwoFactor: true). amr claim'i baştaki çıktıdaki gibi pwd; passkey'i ayırt etmek istiyorsan kendi endpoint'inde PerformPasskeyAssertionAsync ile doğrulayıp SignInWithClaimsAsync ile kendi claim'ini (örneğin RFC (Request for Comments) 8176'daki hwk ya da swk) eklemen gerekiyor. Attestation statement doğrulanmıyor; kurumsal bir "yalnız şu cihazlar" politikası için VerifyAttestationStatement callback'ini kendin doldurmalısın. Hazır arayüz yalnız Blazor Web App şablonunda.

.NET 11 ile Identity, passkey'e AAGUID'den (Authenticator Attestation Globally Unique Identifier) tanınan sağlayıcılar için otomatik bir görünen ad vermeye başlıyor (Google Password Manager, iCloud Keychain, Windows Hello gibi). .NET 11 henüz GA (General Availability) olmadığı için burada kod vermiyorum.

Üretimde Dikkat

HTTPS (HTTP Secure) zorunlu; lab'ın http://localhost üzerinde çalışması yalnız localhost'un güvenli bağlam sayılmasından. State cookie'si de dahil bütün cookie'ler Secure olmalı. Kullanıcıya ilk passkey'den sonra ikincisini eklemeyi öner ve passkey listesini yönetebileceği (yeniden adlandırma, silme) bir ekran ver; silme ve yeni passkey ekleme işlemlerini 5. yazıdaki step-up'ın arkasına koy.

Ölçüm için Identity'nin .NET 10 metriklerindeki sign-in sayıları ve passkey sayısının zaman içindeki değişimi yeterli bir başlangıç. Passkey'li kullanıcı oranı arttıkça parola sıfırlama ve hesap ele geçirme destek kayıtlarının düşmesi beklenir; bu iki rakamı yan yana izle.

Sık Düşülen Tuzaklar

Birincisi, ServerDomain'i boş bırakmak. Gördüğün gibi RP ID Host header'ından geliyor. İkincisi, IPasskeyHandler<TUser>'ı doğrudan çağırıp attestation state'i istemciye taşımak.

Üçüncüsü, PasskeySignInAsync'in InvalidOperationException'ını yakalamamak ve süresi dolmuş her girişte 500 üretmek. Dördüncüsü, passkey ile girişi amr'a bakarak MFA (Multi-Factor Authentication) saymak; Identity pwd yazıyor.

Beşincisi, conditional isteği iptal etmeden modal isteği başlatmak. Tarayıcı ikinci isteği reddeder ve kullanıcı "passkey ile giriş" düğmesine bastığında hiçbir şey olmaz.

Küçük Bir Deneme

★☆☆ Lab'ı Passkeys:ServerDomain olmadan çalıştır ve sahte Host header'ıyla creation options iste. Ardından appsettings.json'a "AllowedHosts": "localhost" ekleyip aynı isteğin ne döndüğüne bak.

★★☆ Passkey ile giriş için ayrı bir endpoint yaz: PerformPasskeyAssertionAsync ile doğrula, AddOrUpdatePasskeyAsync ile sayacı güncelle ve SignInWithClaimsAsync ile amr olarak hwk ver. 5. yazıdaki step-up policy'sini passkey girişini de kabul edecek şekilde genişlet.

★★★ E2E (end-to-end) script'ine conditional UI senaryosu ekle: sayfayı yükle, WebAuthn.getCredentials ile credential'ın varlığını doğrula, ardından Runtime.evaluate ile conditional isteğin beklediğini ve modal düğmeye basınca iptal edildiğini göster.

Kimliği doğrulamanın modern yollarını gördük: cookie, Identity, MFA ve passkey. Bir sonraki yazıda kimliği taşıyan token'lara dönüyoruz: JWT'nin (JSON Web Token) içi, JWS (JSON Web Signature) ve JWE (JSON Web Encryption), algoritma seçimi, kid, JWKS (JSON Web Key Set) ve kesintisiz signing key rotation.