GuyStore — Bug Fixes & Issue Log

Complete record of all reported issues and resolutions · April – June 2026

Table of Contents

Section A covers product image fixes (April 2026). Section B covers passkey / WebAuthn fixes (June 2026).

A — Product Image Fixes

  1. Issue 1 - All products show "No Image" after SQL Server migration
  2. Fix 1 - Added ImageUrl fallback + seeded picsum.photos URLs
  3. Issue 2 - Homepage not showing images; Shop images mismatch product titles
  4. Fix 2 - SVG binary images stored in SQL Server via migration
  5. Issue 3 - Images still not showing on homepage and Shop page
  6. Fix 3 - Missing ResponseCaching middleware (root cause)
  7. Image Fixes — Final State Summary

B — Passkey / WebAuthn Fixes

  1. Issue 4 - "Cannot read properties of undefined (reading 'replace')" on Passkey sign-in
  2. Fix 4 - Use Fido2NetLib's own ToJson() / FromJson() instead of Newtonsoft.Json
  3. Issue 5 - Passkey RP ID mismatch when accessed via Cloudflare tunnel (guydev.ca)
  4. Fix 5 - Environment-split Fido2 config: guydev.ca in appsettings.json, localhost in appsettings.Development.json
  5. Issue 6 - "Sign in with Passkey" fails on first visit to guydev.ca — no passkey registered for this domain
  6. Fix 6 - Register a passkey via Account → Passkeys after password login; added hint text on login page
  7. Issue 7 - 500 error when clicking Passkeys button on Profile page
  8. Fix 7 - PasskeyController.Manage() was returning the wrong view; pointed to the correct path
  9. Issue 8 - "Microsoft Authenticator doesn't support this passkey" — missing /.well-known/webauthn endpoint
  10. Fix 8 - Added /.well-known/webauthn middleware for WebAuthn Related Origins validation
  11. Issue 9 - Same MS Authenticator error persists despite /.well-known/webauthn returning correct JSON
  12. Fix 9 - Changed ServerDomain to www.guydev.ca to eliminate Related Origins check entirely
  13. Investigation Note — caBLE transport, iOS passkey provider, and diagnostic approach
  14. Issue 10 - "The input is not a valid Base-64 string" error during passkey registration
  15. Fix 10 - Switched CompleteRegistration and CompleteAuthentication from Newtonsoft.Json to System.Text.Json
  16. Issue 11 - iPhone passkey registration shows provider popup instead of Face ID; popup frozen or shows only "Open AutoFill & Passwords"
  17. Fix 11 - Added AuthenticatorAttachment.Platform to PasskeyService + iCloud re-login to restore platform passkey provider
  18. Passkey Fixes — Final State Summary

Issue 1 - All products show "No Image" after SQL Server migration

👤
User

All the products show No Image now after migrated to SQL Server, is it possible to fix it?

Context: the app was previously running with an EF Core in-memory database. Some product images had been uploaded through the Admin → Products UI. After switching to SQL Server and running migrations, all images disappeared.

🤖
Root Cause Analysis

Root Cause — in-memory EF store does not persist; DataSeeder never seeded images

Where did the images go? The EF Core in-memory database is destroyed every time the application process stops or restarts. Any product images uploaded through the admin UI existed only in that in-memory store — they were never written to disk or a real database. When the app switched to SQL Server, it started with a completely fresh database where no images had ever been stored.

Why DataSeeder didn't help: Data/DataSeeder.cs created all 8 products with ImageData = null and ImageMimeType = null — images were never part of the seed. The seeder only created product names, prices, and descriptions.

Why the view showed "No Image": Controllers/ShopController.cs set HasImage = p.ImageData != null. Since all products had ImageData = null, every card evaluated to false and fell back to the "No Image" placeholder.

Key inference: This is not a migration bug — the migration ran correctly. The data simply never existed in a persisted form. The solution is to seed images into the new SQL Server database rather than trying to recover them.


Fix 1 - Added ImageUrl fallback + seeded picsum.photos URLs

🤖
Files changed
FileChange
Models/Product.csAdded public string? ImageUrl { get; set; }
Data/DataSeeder.csAdded SeedImageUrls dictionary with picsum.photos URLs; seeds ImageUrl on initial insert; added backfill loop for existing rows where both ImageData and ImageUrl are null
Models/ViewModels/ProductViewModels.csAdded ImageUrl to ShopProductViewModel and ProductViewModel
Controllers/ShopController.csMapped ImageUrl = p.ImageUrl in both Index and Detail actions
Views/Shop/Index.cshtmlImage src falls back to p.ImageUrl before the "No Image" placeholder
Views/Shop/Detail.cshtmlSame fallback added
Areas/Admin/Views/Products/Index.cshtmlAdmin thumbnail uses ImageUrl when no binary exists
Areas/Admin/Views/Products/_ProductForm.cshtmlAdded URL image preview and ImageUrl text input field
Areas/Admin/Controllers/ProductsController.csReads/writes ImageUrl in Create and Edit actions
Migrations/20260429060000_AddProductImageUrl.*Adds nullable nvarchar(max) column ImageUrl to Products table

Image priority logic (views): uploaded binary file → ImageUrl → "No Image" placeholder.

// Shop/Index.cshtml - image src
p.HasImage
    ? Url.Action("Image", "Shop", new { id = p.Id })
    : (p.ImageUrl ?? "https://placehold.co/400x300/cccccc/666?text=No+Image")

Trade-off note: picsum.photos returns real-world photographs (nature, architecture, people) keyed by a seed word — the images are deterministic but have no visual connection to the product names. A "Wireless Keyboard" seed shows a random landscape. This was an acceptable first pass to get images displayed, but the mismatch was reported immediately as Issue 2, which replaced all placeholder URLs with product-specific SVG illustrations stored directly in SQL Server.


Issue 2 - Homepage not showing images; Shop images mismatch product titles

👤
User

The homepage does not show images of products, the product images in Shop page are not matched with the titles of products. I want to save the appropriate binary images saved in the SQL Server database. Correct it through a new migration script in Migrations folder.

🤖
Root Cause Analysis

Homepage - ImageUrl fallback missing

Views/Home/Index.cshtml line 58 checked p.ImageData != null but never fell back to p.ImageUrl. The model passed to the homepage is IEnumerable<Product> (raw EF entities), so p.ImageUrl was available but unused.

Shop - picsum.photos images do not match product names

The picsum.photos seed-based URL format (https://picsum.photos/seed/keyboard/400/300) returns deterministic but random real-world photos - nature, architecture, people - that have no visual connection to the seeded products. A keyboard product showing a landscape photo is misleading.


Fix 2 - SVG binary images stored in SQL Server via migration

🤖
Approach

Created custom SVG illustrations for all 8 products and stored them directly as varbinary(max) binary data in SQL Server via an EF migration. Each SVG is self-contained ASCII XML with no external dependencies.

Why SVG over other options:

OptionConsidered?Outcome
Upload real PNGs/JPEGs at runtimeNoRequires a file-upload workflow or static assets; not reproducible via migration
Reference external image URLs (picsum, CDN)Yes — done in Fix 1Images look wrong (random real photos, not product illustrations); breaks if external URL goes offline
Static SVG files in wwwrootNoNot tied to the database; lost or mismatched if DB is rebuilt without the static folder
SVG stored as varbinary in SQL Server via migrationYes — chosenSelf-contained in the DB; reproducible on any machine by running migrations; no external dependencies; browser renders SVG inside <img> tags natively with Content-Type: image/svg+xml; lightweight (hundreds of bytes per image)

SQL technique used in migration:

-- T-SQL inside migrationBuilder.Sql()
UPDATE Products
SET ImageData    = CONVERT(varbinary(max), '<svg ...>...</svg>'),
    ImageMimeType = 'image/svg+xml',
    ImageUrl      = NULL
WHERE Name = 'Wireless Keyboard'

CONVERT(varbinary(max), '...') stores the ASCII SVG string as raw bytes. When served by ShopController.Image() with Content-Type: image/svg+xml, all modern browsers render the SVG inside <img> tags correctly.

🤖
SVG designs - one per product
ProductTheme colourVisual design
Wireless Keyboard#1a56db (blue)Keyboard body with 3 rows of key rectangles + space bar
USB-C Hub 7-in-1#0891b2 (teal)Hub body with 6 port rectangles on top, 2 USB-A ports + USB-C ring
Ergonomic Mouse#7c3aed (purple)Vertical ellipse body, dome top, scroll wheel
Monitor Stand#475569 (slate)Monitor bezel + screen + neck + base
Laptop Backpack#059669 (green)Rounded backpack body with main + front pockets, handle, buckle
Desk Lamp LED#d97706 (amber)Articulated arm + rotated ellipse shade + lens centre + base
Noise Cancelling Headphones#1e3a5f (navy)Curved SVG path headband + padded earcups (indigo inner)
Webcam 1080p#dc2626 (red)Camera body + concentric lens circles (blue glass) + mount + indicator LED
🤖
Files changed
FileChange
Views/Home/Index.cshtmlFixed image src to fall back to p.ImageUrl (matching the Shop page pattern)
Data/DataSeeder.csBackfill condition tightened: only runs when both ImageData == null AND ImageUrl == null - prevents overwriting products that already have binary images
Migrations/20260429080000_SeedProductImages.cs8 × migrationBuilder.Sql() calls - UPDATE each product with SVG binary + sets ImageUrl = NULL
Migrations/20260429080000_SeedProductImages.Designer.csEF snapshot file for the migration (schema unchanged - data-only migration)

Down() migration (rollback): clears ImageData and ImageMimeType for all SVG-type rows.


Issue 3 - Images still not showing on homepage and Shop page

👤
User

The images are not shown in homepage and Shop page, fix it.

🤖
Root Cause - Missing Response Caching Middleware

InvalidOperationException: IResponseCachingFeature is not available

ShopController.Image() carries this attribute:

[ResponseCache(Duration = 3600, VaryByQueryKeys = ["id"])]

The VaryByQueryKeys property requires ResponseCachingMiddleware to be registered in the pipeline. At runtime it calls context.HttpContext.Features.Get<IResponseCachingFeature>() and throws InvalidOperationException if the feature is not present — returning a 500 for every image request.

The gotcha with VaryByQueryKeys: Most [ResponseCache] attributes work without any middleware registration (they just emit Cache-Control headers). VaryByQueryKeys is the exception — it needs the server-side response cache middleware to split cached content by query string. There is no compiler warning or startup-time error; the crash only happens at the moment the action is invoked. It is easy to add the attribute and forget the middleware.

Why was this hidden until now? Before the migrations added binary data, HasImage was always false for every product. Views used placehold.co URLs directly — the /Shop/Image/{id} endpoint was never called, so the exception was never triggered. Once ImageData was populated by Fix 2's migration, HasImage became true and every product card tried to load from /Shop/Image/{id}, hitting the 500 on every request simultaneously.


Fix 3 - Register ResponseCaching middleware in Program.cs

🤖
Two-line fix in Program.cs
// Services section - add after AddControllersWithViews()
builder.Services.AddResponseCaching();

// Middleware pipeline - add after app.UseRouting()
app.UseResponseCaching();

With the middleware registered, IResponseCachingFeature is available on every request. The ShopController.Image() action now:

  • Serves the SVG bytes with Content-Type: image/svg+xml
  • Instructs clients to cache the image for 1 hour (Cache-Control: public, max-age=3600)
  • Varies the server-side cache by the id query parameter so each product's image is stored separately

Image Fixes — Final State Summary

All image issues resolved — 2026-04-29

ItemState
Image storageBinary varbinary(max) in SQL Server
MIME typeimage/svg+xml
Image endpointGET /Shop/Image?id={n} - served by ShopController.Image()
Response caching1-hour client + server cache, varied by id
Fallback chainBinary upload → ImageUrl (admin-set URL) → "No Image" placeholder
Migrations added20260429060000_AddProductImageUrl (schema), 20260429080000_SeedProductImages (data)
HomepageFixed - uses same image fallback chain as Shop page
Admin product formShows current image (binary or URL), accepts new upload, allows editing ImageUrl

Key files touched across all three fixes

FileWhat changed
Models/Product.csAdded ImageUrl property
Data/DataSeeder.csSeed image URLs + smart backfill (skips rows with binary data)
Models/ViewModels/ProductViewModels.csAdded ImageUrl to both view models
Controllers/ShopController.csMaps ImageUrl into view models
Views/Home/Index.cshtmlAdded ImageUrl fallback
Views/Shop/Index.cshtmlAdded ImageUrl fallback
Views/Shop/Detail.cshtmlAdded ImageUrl fallback
Areas/Admin/Views/Products/Index.cshtmlAdmin thumbnail uses ImageUrl when no binary
Areas/Admin/Views/Products/_ProductForm.cshtmlURL image preview + ImageUrl text field
Areas/Admin/Controllers/ProductsController.csHandles ImageUrl in Create / Edit
Program.csAddResponseCaching() + UseResponseCaching()
Migrations/20260429060000_AddProductImageUrl.*Adds ImageUrl nvarchar(max) column
Migrations/20260429080000_SeedProductImages.*Seeds SVG binary images for all 8 products

Issue 4 - "Cannot read properties of undefined (reading 'replace')" on Passkey sign-in

👤
User

When I login by 'Sign in with Passkey', I got Cannot read properties of undefined (reading 'replace') error.

🤖
Root Cause Analysis

Root Cause — Newtonsoft.Json ignores System.Text.Json attributes; Fido2NetLib v4 uses STJ exclusively

The JavaScript error occurs in passkey.js inside base64urlToArrayBuffer() at the first .replace() call when options.challenge is undefined.

Background on the library: The NuGet package used is Fido2.AspNet v4.0.1 (namespace Fido2NetLib). Starting with v4, the library switched from Newtonsoft.Json to System.Text.Json throughout. All its model properties are decorated with [JsonPropertyName("camelCaseName")] (System.Text.Json attribute), which controls the JSON key name when using STJ.

Why the mismatch happens: PasskeyController.BeginAuthentication() was serialising the AssertionOptions object with JsonConvert.SerializeObject() (Newtonsoft.Json). Newtonsoft does not read [JsonPropertyName] attributes — those are STJ-only. Newtonsoft falls back to its own naming convention (C# property names → PascalCase by default). The browser therefore receives { "Challenge": "...", "AllowCredentials": [...] } instead of { "challenge": "...", "allowCredentials": [...] } (the lowercase keys the WebAuthn spec requires). options.challenge is always undefined in JavaScript, and the first .replace() call crashes.

Why it wasn't obvious: The server returned HTTP 200 with a JSON body — there was no error on the server side. The bug was silent until JavaScript tried to read the wrong-cased keys.


Fix 4 - Use Fido2NetLib's own ToJson() / FromJson() instead of Newtonsoft.Json

🤖
Fix applied in Controllers/PasskeyController.cs

Replaced all Newtonsoft.Json calls for Fido2NetLib types with the library's own serialisation methods, which use System.Text.Json internally and respect the [JsonPropertyName] attributes:

BeforeAfter
JsonConvert.SerializeObject(options)options.ToJson()
JsonConvert.DeserializeObject<AssertionOptions>(json)AssertionOptions.FromJson(json)
JsonConvert.DeserializeObject<CredentialCreateOptions>(json)CredentialCreateOptions.FromJson(json)

Applied to both the registration and authentication flows for consistency.

BeginAuthentication() — session storage & response

// Before
HttpContext.Session.SetString(AuthSessionKey, JsonConvert.SerializeObject(options));
return Content(JsonConvert.SerializeObject(options), "application/json");

// After
HttpContext.Session.SetString(AuthSessionKey, options.ToJson());
return Content(options.ToJson(), "application/json");

CompleteAuthentication() — session read

// Before
var options = JsonConvert.DeserializeObject<AssertionOptions>(json)!;

// After
var options = AssertionOptions.FromJson(json);

Same pattern applied to BeginRegistration() / CompleteRegistration()

// BeginRegistration — session + response
HttpContext.Session.SetString(RegSessionKey, options.ToJson());
return Content(options.ToJson(), "application/json");

// CompleteRegistration — session read
var options = CredentialCreateOptions.FromJson(json);

Rule: Always use the library's own ToJson() / FromJson() methods (or System.Text.Json.JsonSerializer) when serialising Fido2NetLib objects — never JsonConvert.


Issue 5 - Passkey RP ID mismatch when accessed via Cloudflare tunnel (guydev.ca)

👤
User

When I deploy this app to my local IIS and access it through Cloudflare and Cloudflared tunnel, I got "The relying party ID is not a registrable domain suffix of, nor equal to the current domain. Subsequently, an attempt to fetch the .well-known/webauthn resource of the claimed RP ID failed." error. If I access it directly from my VS 2022, it ran OK.

🤖
Root Cause Analysis

Root Cause — two simultaneous failures: RP ID locked to "localhost", and Origins list missing guydev.ca

Failure 1 — RP ID mismatch (browser-side check): WebAuthn's security model requires the Relying Party ID (Fido2:ServerDomain) to be either equal to or a registrable domain suffix of the origin the browser is visiting. When the app is reached via https://www.guydev.ca, the browser's effective domain is www.guydev.ca. The RP ID was localhost, which is neither equal to guydev.ca nor a suffix of it. The browser rejects the ceremony before anything reaches the server — this is the error message the user sees.

Failure 2 — Origins not whitelisted (server-side check): Even if the browser had somehow accepted the ceremony, Fido2NetLib verifies on the server that the origin claim embedded in the authenticator's clientDataJSON is in the Fido2:Origins list. Fido2:Origins only contained http://localhost:5090 and https://localhost:7091https://www.guydev.ca was absent, so the server would have rejected it too. Both the RP ID and the Origins list needed updating.

Why it worked in VS 2022: Visual Studio sets ASPNETCORE_ENVIRONMENT=Development automatically. The app loaded appsettings.json (ServerDomain: localhost, Origins: localhost ports) — matching the browser origin exactly. IIS deployed the same appsettings.json as the Production environment with no Development override, and the browser origin was now https://www.guydev.ca, causing both checks to fail simultaneously.

How we confirmed it was a config issue and not a code bug: The error message explicitly names the RP ID mismatch. Checking appsettings.json showed ServerDomain: "localhost" — an obvious mismatch once seen. No code logic was wrong; only the configuration values were environment-specific.


Fix 5 - Environment-split Fido2 config: guydev.ca in appsettings.json, localhost in appsettings.Development.json

🤖
How ASP.NET Core config layering works

ASP.NET Core loads appsettings.json first, then overlays appsettings.{Environment}.json. Keys present in the environment-specific file replace the base values; anything absent falls through from the base. This makes it the natural place to split per-environment WebAuthn config without any code changes.

EnvironmentFiles loadedEffective Fido2 config
VS 2022 / dotnet runappsettings.jsonappsettings.Development.jsonlocalhost / localhost ports
IIS (Cloudflare)appsettings.json onlyguydev.ca / guydev.ca origins

appsettings.json — production values (IIS + Cloudflare)

"Fido2": {
  "ServerDomain": "guydev.ca",
  "ServerName": "GuyStore",
  "Origins": "https://www.guydev.ca,https://guydev.ca"
}

appsettings.Development.json — dev overrides (VS 2022)

"Fido2": {
  "ServerDomain": "localhost",
  "ServerName": "GuyStore",
  "Origins": "http://localhost:5090,https://localhost:7091"
}

Important: Passkeys are domain-bound. A passkey registered on localhost will not work on guydev.ca and vice versa — the credential's RP ID is baked in at registration time by the authenticator. Users must re-register their passkey after switching environments.

Note — intermediate state: Fix 5 initially set ServerDomain to "guydev.ca" (the apex domain). This was later changed to "www.guydev.ca" in Fix 9 to resolve a Cloudflare redirect problem. The value shown above reflects Fix 5's intent; the current production value is "www.guydev.ca".

Rule: Fido2:ServerDomain must always equal the public hostname (no port, no protocol) that users browse to. Fido2:Origins must include every origin (scheme://host:port) the app is reachable from in that environment.


Issue 6 - "Sign in with Passkey" fails on first visit to guydev.ca — no passkey registered for this domain

👤
User

It is my first time to login to this site (guydev.ca) with Passkey, so my phone does not have any Passkey of this site. The login process after scanning the QR code failed due to this issue. What should I do or should the codes need to be adjusted to provide the key or something else?

🤖
Explanation — Not a Bug, Expected WebAuthn Behaviour

Why it failed — passkeys are bound to both domain AND device

WebAuthn passkeys are domain-bound and device-bound. Two separate bindings are enforced:

Domain binding (RP ID): A passkey registered under RP ID guydev.ca can only be used on guydev.ca. A passkey registered on localhost cannot be used on guydev.ca, and vice versa — the authenticator embeds the RP ID at registration time and the browser enforces this on every sign-in. There is no way to migrate or transfer a passkey across domains.

Device binding: A passkey registered using Windows Hello on a PC is stored in that PC's TPM or credential store and cannot be used from an iPhone (unless the passkey was explicitly synced via iCloud Keychain, Google Password Manager, or a similar cross-device sync mechanism). Scanning a QR code only works if the phone itself already has a passkey registered for that domain — the QR code is the transport, not the credential.

When the user scanned the QR code, the phone looked for an existing passkey for guydev.ca, found none (because no passkey had ever been registered on that domain or synced to that phone), and the ceremony failed. This is not a bug — authentication and registration are separate flows. You cannot sign in with something that has never been registered.

One-time setup — what to do on first use of guydev.ca

  1. Open https://www.guydev.ca and sign in with your email and password (the regular form).
  2. Go to Account → Passkeys.
  3. Click Add Passkey and follow the browser / phone prompt — this creates and stores a passkey for guydev.ca on your device.
  4. Sign out. From now on, Sign in with Passkey works on this domain.

Fix 6 - Added hint text on the login page pointing first-time users to Account → Passkeys

🤖
One-line UI change in Views/Account/Login.cshtml

The login page showed the passkey button with no explanation that registration is required first. Added a small hint below the button:

<p class="text-muted small text-center mb-0" style="font-size:.78rem">
    First time? Sign in with your password above, then go to
    <strong>Account → Passkeys</strong> to register a passkey for this device.
</p>
ConceptDetail
Passkey registrationCreates the credential. Requires being already logged in. Done once per domain per device.
Passkey authenticationUses an existing credential. Will fail if none has been registered for that RP ID on that device.
Domain bindingA passkey for localhost is entirely separate from one for guydev.ca — must re-register on every new domain.
Device bindingA passkey registered using Windows Hello stays on that PC. A passkey registered using Face ID stays on that iPhone (unless synced via iCloud Keychain). You must register separately on each device, or use a passkey sync service.
QR code flowCross-device authentication — your phone acts as the authenticator for a desktop browser. The phone must already have a passkey registered for that domain; the QR code provides the communication channel, not the credential.
iCloud Keychain syncWhen passkeys are stored in iCloud Keychain, they sync across all of the user's Apple devices signed into the same Apple ID. This is the exception to "device-bound" — the credential lives in the cloud, not the hardware.

Issue 7 - 500 error when clicking Passkeys button on Profile page

👤
User

When I register an account in guydev.ca, got approved then I login again. After it, I hit Passkey button in the profile page, I got "Error. An error occurred while processing your request." (HTTP 500 — production error page, no detail shown).

🤖
Root Cause Analysis

Root Cause — PasskeyController.Manage() returned the wrong view

The Profile page has <a asp-controller="Passkey" asp-action="Manage">Passkeys</a>, which navigates to GET /Passkey/Manage. The Manage() action ended with return View().

ASP.NET Core's view engine resolves a bare return View() by looking for Views/{ControllerName}/{ActionName}.cshtml — in this case Views/Passkey/Manage.cshtml. That file does not exist. There is no Views/Passkey/ folder at all; the passkey management view lives at Views/Account/Passkeys.cshtml.

In production the view-not-found exception is swallowed by the global error handler, producing the generic 500 page. The mismatch went unnoticed because the Passkeys page had never been exercised on the deployed site before.


Fix 7 - Passed the explicit view path to return View() in PasskeyController.Manage()

🤖
One-line fix in Controllers/PasskeyController.cs
// Before
return View();

// After
return View("~/Views/Account/Passkeys.cshtml");

Passing a ~/Views/… path overrides the convention-based lookup and points the view engine directly at the correct file regardless of which controller is handling the request.

ItemValue
ControllerPasskeyController (route prefix /Passkey)
ActionManage()GET /Passkey/Manage
Convention-based path (wrong)Views/Passkey/Manage.cshtml — does not exist
Actual view path (correct)Views/Account/Passkeys.cshtml

Issue 8 - "Microsoft Authenticator doesn't support this passkey" during cross-device registration

👤
User

I got "Microsoft Authenticator doesn't support this passkey" error when trying to register a passkey.

🤖
Root Cause Analysis

Root Cause — Missing /.well-known/webauthn endpoint (Related Origins validation)

The app is served at https://www.guydev.ca but the WebAuthn RP ID is set to the apex domain guydev.ca. These are different — guydev.ca is a registrable domain suffix of www.guydev.ca, not an exact match.

The WebAuthn specification's Related Origins feature requires that when an RP ID is a parent domain of the actual origin, the authenticator must verify the claim by fetching https://{rpId}/.well-known/webauthn and confirming that the JSON response lists the origin as allowed. Cross-device authenticators (including Microsoft Authenticator and Google's FIDO2 stack) enforce this strictly.

Because no such endpoint existed on the server, Microsoft Authenticator fetched https://guydev.ca/.well-known/webauthn, received a 404, and rejected the ceremony with "Microsoft Authenticator doesn't support this passkey" — a misleading message that really means "RP ID ownership could not be verified".

Note: Same-device authenticators (Windows Hello, Face ID, Touch ID) often skip the /.well-known/webauthn check because they rely on the browser's same-origin policy instead. This is why the error appears only with Microsoft Authenticator (a cross-device/roaming authenticator).


Fix 8 - Added /.well-known/webauthn endpoint for WebAuthn Related Origins validation

🤖
Added inline middleware in Program.cs — before UseRouting / UseSession

First attempt (failed): Adding the endpoint with app.MapGet("/.well-known/webauthn", ...) registered it as a normal controller route. In ASP.NET Core, MapGet endpoints execute after the full middleware pipeline — including UseSession and UseAuthentication. This meant every response from the endpoint carried Set-Cookie headers for the session and authentication cookies. Some cross-device authenticators (Microsoft Authenticator included) reject the /.well-known/webauthn response if it contains unexpected headers, treating it as an untrusted or tampered response.

Working approach: Implemented as a raw app.Use() middleware placed before UseRouting, UseSession, UseAuthentication, and UseAuthorization. This short-circuits the request before any cookie or auth header touches the response — the JSON is returned cleanly with only Content-Type: application/json.

Note: Despite Fix 8 working correctly (the endpoint returns valid JSON), the MS Authenticator error still persisted — see Issue 9 for the deeper Cloudflare redirect root cause.

// Program.cs — after UseStaticFiles(), before UseRouting()
app.Use(async (context, next) =>
{
    if (context.Request.Method == "GET" &&
        context.Request.Path.Equals("/.well-known/webauthn", StringComparison.OrdinalIgnoreCase))
    {
        var cfg = context.RequestServices.GetRequiredService<IConfiguration>();
        var origins = (cfg["Fido2:Origins"] ?? string.Empty)
            .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
        context.Response.ContentType = "application/json; charset=utf-8";
        await context.Response.WriteAsJsonAsync(new { origins });
        return; // short-circuit — session/auth middleware never runs
    }
    await next(context);
});

What the endpoint returns on guydev.ca (production):

GET https://guydev.ca/.well-known/webauthn

{
  "origins": [
    "https://www.guydev.ca",
    "https://guydev.ca",
    "https://www2.guydev.ca"
  ]
}

https://www2.guydev.ca is the Docker-hosted instance — added to Fido2:Origins in appsettings.json so passkeys registered on any of the three origins are accepted by the server and listed in the /.well-known/webauthn response.

ConceptDetail
RP IDguydev.ca — the apex domain, a suffix of www.guydev.ca
Related Origins checkRequired when RP ID ≠ effective domain of the origin. Authenticator fetches https://{rpId}/.well-known/webauthn
Who enforces itCross-device (roaming) authenticators: Microsoft Authenticator, Android FIDO2, etc. Same-device authenticators (Windows Hello, Face ID) typically skip it
Without this endpoint404 → authenticator rejects with "doesn't support this passkey"
With this endpoint200 + JSON listing allowed origins → authenticator proceeds normally
Development impactNone — localhost RP ID exactly equals the localhost hostname so no Related Origins check is triggered

Issue 9 - Same MS Authenticator error persists despite /.well-known/webauthn returning correct JSON

👤
User

Same error again — "Microsoft Authenticator doesn't support this passkey." Verified that https://www.guydev.ca/.well-known/webauthn now returns {"origins":["https://www.guydev.ca","https://guydev.ca","https://www2.guydev.ca"]}, but the error continues after deploying Fix 8.

🤖
Root Cause Analysis

Root Cause — Cloudflare apex-to-www redirect intercepts the /.well-known/webauthn lookup

The RP ID was guydev.ca (apex domain). WebAuthn Related Origins validation requires the authenticator to fetch https://guydev.ca/.well-known/webauthn — specifically from the RP ID host, not the page origin.

Cloudflare is configured to redirect all traffic from the apex domain (guydev.ca) to the www subdomain (www.guydev.ca) via an HTTP 301/302 redirect. When Microsoft Authenticator fetches https://guydev.ca/.well-known/webauthn, Cloudflare returns a redirect response instead of the JSON payload.

The WebAuthn spec and most authenticator implementations do not follow redirects on the /.well-known/webauthn lookup — a redirect is treated as failure. So the endpoint at https://www.guydev.ca/.well-known/webauthn returned correct JSON, but that URL was never actually fetched for the Related Origins check; guydev.ca was fetched and redirected, causing the rejection.

This is a subtle but critical distinction: the Related Origins lookup always goes to https://{rpId}/.well-known/webauthn, and if the RP ID host redirects rather than responds, the ceremony fails regardless of what the redirected destination returns.


Fix 9 - Changed ServerDomain to www.guydev.ca to eliminate Related Origins check entirely

🤖
Changed Fido2:ServerDomain in appsettings.json from "guydev.ca" to "www.guydev.ca"

The cleanest fix is to make the RP ID exactly equal to the effective domain of the serving origin. When the RP ID and the origin hostname match exactly, the WebAuthn spec does not require a Related Origins check — the authenticator accepts the claim without fetching /.well-known/webauthn at all.

Before — in appsettings.json:

"Fido2": {
  "ServerDomain": "guydev.ca",
  "ServerName": "GuyStore",
  "Origins": "https://www.guydev.ca,https://guydev.ca,https://www2.guydev.ca"
}

After — in appsettings.json:

"Fido2": {
  "ServerDomain": "www.guydev.ca",
  "ServerName": "GuyStore",
  "Origins": "https://www.guydev.ca,https://guydev.ca,https://www2.guydev.ca"
}

The appsettings.Development.json override continues to set ServerDomain: "localhost", so VS 2022 local development is unaffected.

How we diagnosed this was a redirect problem (not a missing endpoint):

  1. Verified https://www.guydev.ca/.well-known/webauthn in a browser → returned correct JSON with all three origins. So the endpoint itself was working.
  2. Tested https://guydev.ca/.well-known/webauthn (the RP ID host, apex domain) → Cloudflare returned an HTTP 301 redirect to https://www.guydev.ca/.well-known/webauthn instead of JSON.
  3. Cross-referenced with the WebAuthn spec: authenticators must not follow redirects on /.well-known/webauthn. A redirect is treated as a failure.
  4. Conclusion: the endpoint was reachable only at www.guydev.ca, but authenticators only ever fetched it from guydev.ca (the RP ID host). The fix is to change the RP ID to match where the endpoint actually lives.
ScenarioDetail
RP ID (production)www.guydev.ca — now an exact match with the serving origin
Related Origins checkNo longer triggered — RP ID equals the effective domain of https://www.guydev.ca
Microsoft Authenticator behaviorDoes not fetch /.well-known/webauthn at all; proceeds directly with the ceremony
/.well-known/webauthn endpointRemains in place — still needed if www2.guydev.ca (Docker) uses this app as its WebAuthn backend with an apex RP ID
Cloudflare redirect issueBypassed entirely — no RP ID lookup to the apex domain is ever made
Development (VS 2022)Unchanged — appsettings.Development.json overrides ServerDomain to localhost
Existing passkeys registered under old RP IDAny passkeys registered when the RP ID was guydev.ca will fail authentication — users must re-register their passkeys after this change

Key lesson: When hosting behind a CDN or reverse proxy that redirects apex → www, always set the WebAuthn RP ID to the www hostname (the actual serving origin), not the apex domain. Using a parent domain as the RP ID is only safe when the RP ID domain itself responds directly to /.well-known/webauthn without any redirect. Quick diagnostic: test both https://guydev.ca/.well-known/webauthn and https://www.guydev.ca/.well-known/webauthn in a browser — if one redirects, authenticators will reject it.


Investigation Note — caBLE transport, iOS passkey provider, and diagnostic approach

🤖
Background findings while verifying Fix 9

After deploying Fix 9, the MS Authenticator error still occurred via both the QR-code flow from the PC and from the iPhone browser directly. Two separate mechanisms were at play — neither was a code bug, but both are important to understand for future debugging.

1 — QR code flow fails without Bluetooth (caBLE transport)

When a desktop browser shows a WebAuthn QR code and a phone scans it, the underlying protocol is FIDO2 Hybrid transport (also called caBLE — Cloud-Assisted Bluetooth Low Energy). The flow is:

  1. Browser generates a QR code containing encrypted connection parameters.
  2. Phone scans the code and uses Bluetooth Low Energy (BLE) to verify it is physically close to the same PC.
  3. Once BLE proximity is confirmed, the actual CTAP2 messages flow over an encrypted internet relay.
  4. The passkey ceremony completes and the result is returned to the browser.

The message "iPhone needs to connect to this device first" from MS Authenticator means the BLE handshake in step 2 failed. This happens when:

  • Bluetooth is off on the Windows PC (most common cause — many desktops have no BT adapter or it is disabled).
  • Bluetooth is off on the iPhone.
  • The devices are too far apart (>10 m).
  • The browser does not support caBLE (use Chrome or Edge; Firefox support is limited).

This is a transport-layer failure independent of the RP ID or server configuration. Enabling Bluetooth on the PC and keeping the phone nearby resolves it. No code change is required.

2 — iOS passkey provider routes all passkey operations through one app

On iPhone, all passkey (and password autofill) operations are handled by whichever app is configured as the passkey provider in Settings → Passwords → Password Options → Autofill Passwords and Passkeys. If Microsoft Authenticator is the selected provider, every passkey ceremony — including those initiated from Safari directly — goes through MS Authenticator. This explains why using the iPhone browser (Option B) showed the same MS Authenticator error: it was not a server issue.

To use iCloud Keychain instead, enable it in the same settings screen. When multiple providers are enabled, iOS presents a choice sheet each time a passkey is needed. iCloud Keychain is the recommended option for standard WebAuthn configurations.

3 — Diagnostic isolation test: Windows Hello on PC browser

To determine whether the MS Authenticator error was a server config issue or a MS Authenticator-specific compatibility issue, a targeted test was run:

  1. On the PC browser (Chrome/Edge), navigate to https://www.guydev.ca → login → Account → Passkeys → Register.
  2. When the passkey dialog appears, choose "This device" or "Windows Hello PIN / Face" — deliberately avoiding the QR code / phone option.
  3. Complete the Windows Hello biometric prompt.

Result: the Windows Hello ceremony succeeded — the browser accepted the passkey options from the server with no RP ID error. This confirmed the server configuration (ServerDomain: "www.guydev.ca", updated origins) was correct. However, the server then threw a Base-64 error when processing the response — see Issue 10.

4 — iPhone "Choose how to manage passkeys" popup issues and iCloud provider failure

This is covered in full as Issue 11 and Fix 11 below, including the code change (AuthenticatorAttachment.Platform) and the iCloud re-login fix.

ScenarioTransportRequires BluetoothWho handles on iPhone
PC browser → QR code → phoneFIDO2 Hybrid (caBLE)Yes — both PC and phoneWhichever iOS passkey provider is active
iPhone browser → passkeyPlatform (direct)NoWhichever iOS passkey provider is active
PC browser → "This device"Platform (direct)NoWindows Hello / Windows credential manager

Issue 10 - "The input is not a valid Base-64 string" error during passkey registration

👤
User

When registering a passkey using Windows Hello on the PC (which confirmed the RP ID config is correct), the Windows Hello biometric prompt completed successfully but the site returned: "The input is not a valid Base-64 string as it contains a non-base 64 character, more than two padding characters, or an illegal character among the padding characters."

🤖
Root Cause Analysis

Root Cause — Newtonsoft.Json cannot decode base64url byte arrays from Fido2NetLib types

This is the same Newtonsoft vs System.Text.Json serializer mismatch that caused Issue 4, but on the incoming request side.

The browser's WebAuthn API returns byte arrays (challenge, rawId, attestationObject, clientDataJSON, signature, etc.) as base64url — a URL-safe variant that uses - and _ instead of + and /, and omits = padding. passkey.js converts these ArrayBuffers and sends them as base64url strings in the JSON body.

Fido2NetLib v4 declares all its byte[] properties (e.g. RawId, AttestationObject, ClientDataJson, Signature) with [JsonConverter(typeof(Base64UrlConverter))] — a System.Text.Json custom converter that decodes base64url correctly.

PasskeyController.CompleteRegistration and CompleteAuthentication used JsonConvert.DeserializeObject<T> (Newtonsoft.Json). Newtonsoft does not read [JsonConverter] attributes from System.Text.Json and falls back to its own byte[] handling, which uses standard Base64. Standard Base64 rejects - and _ with the error above.

The ceremony appeared to reach the server (the Windows Hello biometric prompt completed successfully) but failed the moment the server tried to parse the authenticator's byte arrays from the request body.


Fix 10 - Switched CompleteRegistration and CompleteAuthentication from Newtonsoft.Json to System.Text.Json

🤖
PasskeyController.cs — replaced JsonConvert with System.Text.Json.JsonSerializer

Beforeusing Newtonsoft.Json at top of file, and in both complete actions:

// using Newtonsoft.Json;  ← removed

request = JsonConvert.DeserializeObject<CompleteRegistrationRequest>(body);
// ...
assertionResponse = JsonConvert.DeserializeObject<AuthenticatorAssertionRawResponse>(body);

After — switched to System.Text.Json.JsonSerializer:

using System.Text.Json;
using System.Text.Json.Serialization;

// CompleteRegistration
request = JsonSerializer.Deserialize<CompleteRegistrationRequest>(body);

// CompleteAuthentication
assertionResponse = JsonSerializer.Deserialize<AuthenticatorAssertionRawResponse>(body);

The CompleteRegistrationRequest wrapper class was also updated to use [JsonPropertyName] (System.Text.Json) instead of [JsonProperty] (Newtonsoft):

private class CompleteRegistrationRequest
{
    [JsonPropertyName("attestationResponse")]
    public AuthenticatorAttestationRawResponse AttestationResponse { get; set; } = null!;

    [JsonPropertyName("credentialName")]
    public string CredentialName { get; set; } = "My Passkey";
}
AspectDetail
Encoding used by browserbase64url — - and _ instead of +//, no = padding
Fido2NetLib byte[] converter[JsonConverter(typeof(Base64UrlConverter))] — System.Text.Json only
Newtonsoft behaviorIgnores [JsonConverter] (STJ) attributes; uses standard Base64 decoder → rejects -/_
System.Text.Json behaviorReads [JsonConverter] attributes on properties; uses Base64UrlConverter → decodes correctly
Affected actionsCompleteRegistration and CompleteAuthentication in PasskeyController.cs
Rule of thumbAny Fido2NetLib type that contains byte[] must be deserialized with System.Text.Json, never Newtonsoft

Issue 11 - iPhone passkey registration shows provider popup instead of Face ID; popup frozen or shows only "Open AutoFill & Passwords"

👤
User (iPhone XR, iOS 18.7.9) — sequence of escalating problems

Attempt 1: Opened GuyStore in iPhone Safari, logged in, went to Account → Passkeys → Register. Instead of a Face ID prompt, iOS showed a QR code option — no iCloud Keychain option visible at all.

After enabling iCloud Keychain (Settings → [Apple ID] → iCloud → Passwords & Keychain → toggle on):

Attempt 2: iOS now showed a "Choose how to manage your passkeys" popup, but tapping any button froze the screen. The page appeared stuck.

After rebooting the iPhone:

Attempt 3: The popup was no longer frozen but showed only one button: "Open 'AutoFill & Passwords'" — no Face ID option, no iCloud Keychain option. Tapping the button just opened the Settings screen where everything looked already enabled.

Checked all relevant settings on iOS 18.7.9: Settings → General → AutoFill & Passwords → Password Options — iCloud Passwords & Keychain was listed and enabled. Face ID was also confirmed set up and enrolled. Nothing appeared to need enabling anywhere.

Same popup appeared on the independent test site https://webauthn.io — so the issue was device-wide, not specific to the app.

🤖
Root Cause Analysis — three layered problems in order

Root Cause 0 — iCloud Keychain not enabled as a passkey provider at all (Attempt 1)

When the passkey provider list in Settings → General → AutoFill & Passwords → Password Options does not include iCloud Passwords & Keychain, iOS has no platform passkey provider to offer. Instead of showing Face ID, the provider picker sheet shows the QR code / cross-device option as the only available path. This is not a QR code flow — it is iOS telling the user "there is no platform passkey provider available; cross-device is your only option."

Fix: Settings → [Apple ID name at the top] → iCloud → Passwords & Keychain → toggle on. This registers iCloud Keychain as the device passkey provider. On the next attempt, iCloud Keychain appeared in the provider picker — but then came the next problem.

Root Cause A — No AuthenticatorAttachment specified; iOS shows provider-selection popup instead of going directly to Face ID (Attempt 2)

When AuthenticatorAttachment is not set in AuthenticatorSelection, the browser allows any type of authenticator (platform or cross-platform). On iOS this triggers a provider-selection sheet asking which passkey manager to use. If multiple providers or unknown state, iOS shows the "Choose how to manage your passkeys" popup rather than going straight to Face ID.

The frozen popup on Attempt 2 was caused by iOS attempting to deep-link to Settings while the navigator.credentials.create() promise was still pending in Safari — the page appeared frozen because the WebAuthn ceremony timeout hadn't fired yet. Rebooting the iPhone cleared the stuck promise.

Root Cause B — iCloud in a silent broken authentication state (Attempt 3 and beyond)

After rebooting, the popup was no longer frozen but showed only "Open 'AutoFill & Passwords'" with no provider options — meaning iOS had no passkey provider available at all, even though iCloud Keychain had just been enabled. This persisted even though:

  • Face ID was set up and enrolled correctly (confirmed in Settings → Face ID & Passcode)
  • iCloud Keychain sync toggle was enabled (Settings → [Apple ID] → iCloud → Passwords & Keychain)
  • Settings → General → AutoFill & Passwords → Password Options showed iCloud Passwords & Keychain as the active provider
  • No error banner or warning appeared anywhere in Settings → Apple ID

Key diagnostic step — webauthn.io isolation test: The same popup appeared on https://webauthn.io, an independent public WebAuthn test site with no connection to GuyStore. This confirmed the failure was device-wide — not a GuyStore code bug, RP ID mismatch, or configuration error. Any app on the device would have seen the same result.

Why Face ID being set up does not fix this: Face ID and the passkey provider are separate systems. Face ID is a biometric sensor. The passkey provider is iCloud Keychain, which is a cloud-backed credential store. Even when Face ID works perfectly, if the passkey provider (iCloud Keychain) is broken, iOS cannot complete the passkey ceremony — it cannot offer the Face ID prompt because there is no provider to store the credential into.

Root cause: iCloud was in a silent expired/broken authentication state. The Passwords provider appeared registered in settings, but internally its session token was stale or corrupted. It could not actually serve as a passkey provider. No visible error surfaced in Settings.


Fix 11 - Added AuthenticatorAttachment.Platform to PasskeyService + iCloud re-login to restore platform passkey provider

🤖
Two-part fix: code change + device fix

Part A — Code change: force platform authenticator in PasskeyService.cs

Added AuthenticatorAttachment = AuthenticatorAttachment.Platform to BeginRegistrationAsync(). This tells the browser to use the device's built-in platform authenticator only, bypassing the provider-selection popup and going directly to Face ID (iPhone) or Windows Hello (PC).

// Services/PasskeyService.cs — BeginRegistrationAsync()

// Before
AuthenticatorSelection = new AuthenticatorSelection
{
    ResidentKey = ResidentKeyRequirement.Required,
    UserVerification = UserVerificationRequirement.Required
    // No attachment specified → iOS shows provider picker popup
},

// After
AuthenticatorSelection = new AuthenticatorSelection
{
    ResidentKey = ResidentKeyRequirement.Required,
    UserVerification = UserVerificationRequirement.Required,
    AuthenticatorAttachment = AuthenticatorAttachment.Platform
    // Platform → iOS goes directly to Face ID; PC goes directly to Windows Hello
},
AspectBefore (no attachment)After (Platform)
iPhone registrationShows "Choose how to manage" provider popupGoes directly to Face ID + iCloud Keychain
PC registrationShows options incl. QR code / cross-deviceGoes directly to Windows Hello
Sign-in (any device)Unchanged — authentication not affectedUnchanged
Cross-device QR code registrationAvailable (via MS Authenticator / cross-platform)Not available — blocked by Platform

The trade-off is intentional: cross-device registration via QR code was unreliable anyway due to the Bluetooth (caBLE) requirement. Platform passkeys (Face ID, Windows Hello) are simpler and more reliable for end users.

Part B — Device fix: sign out and back into iCloud

Even with the Platform attachment code change, the popup persisted — iOS had no working platform provider. The fix was to re-authenticate iCloud:

  1. Settings → tap [Apple ID name] at the top
  2. Scroll down → tap Sign Out → confirm
  3. Sign back in with Apple ID and password
  4. Re-enable iCloud Keychain sync when prompted

This re-initialises the Passwords/passkey provider from scratch. After signing back in, Safari immediately showed the Face ID prompt with no popup — the passkey was saved successfully to iCloud Keychain (visible as "iPhone Passkey" in the Account → Passkeys management page).

Diagnostic rule for future incidents: If a platform passkey provider issue is suspected, test on https://webauthn.io before debugging the app. If webauthn.io shows the same failure, the issue is the device — not the code. Re-authenticating iCloud resolves the majority of silent provider failures on iPhone.

iOS 18.x relevant settings paths (for reference):

What to checkiOS 18 path
Enable iCloud Keychain as passkey providerSettings → [Apple ID name at top] → iCloud → Passwords & Keychain → toggle on
Check which app is the active passkey providerSettings → General → AutoFill & Passwords → Password Options → Autofill Passwords and Passkeys
Confirm Face ID is enrolledSettings → Face ID & Passcode → Face ID should show "Set Up"
Re-authenticate iCloud (nuclear option)Settings → [Apple ID name at top] → Sign Out → sign back in
SymptomLikely causeFix
iOS shows QR code instead of Face ID (no iCloud option at all)iCloud Keychain not enabled as a passkey providerSettings → [Apple ID] → iCloud → Passwords & Keychain → enable
Provider popup appears but freezes when tappediOS deep-links to Settings while WebAuthn ceremony is still pending in SafariReboot iPhone; the pending ceremony timeout clears the stuck state
Popup shows only "Open AutoFill & Passwords" — no provider optionsNo passkey provider is functioning — iCloud Passwords silently broken despite appearing enabledSign out and back into iCloud (Part B above)
All settings look correct but still failsSettings display what is registered, not what is working — iCloud can look configured but be internally stuckiCloud re-login
Same popup on webauthn.io (independent site)Confirmed device-wide provider failure — not the app's code or configiCloud re-login
Face ID is set up but passkeys still don't workFace ID (biometric sensor) and iCloud Keychain (credential store) are separate systems — both must be healthyVerify iCloud → Passwords is syncing; re-login if unsure

Passkey Fixes — Final State Summary

Passkey issue resolution — June 2026 · All 11 issues resolved

Config / ComponentCurrent state
Fido2:ServerDomain (production)www.guydev.ca — exact match with the Cloudflare-served origin; no Related Origins check needed
Fido2:ServerDomain (development)localhost via appsettings.Development.json override — VS 2022 unaffected
Fido2:Originshttps://www.guydev.ca, https://guydev.ca, https://www2.guydev.ca
/.well-known/webauthnRaw app.Use() middleware in Program.cs — placed before UseRouting / UseSession so response has no cookies; returns all three origins as JSON
Serialisation (outgoing options)options.ToJson() / AssertionOptions.FromJson() / CredentialCreateOptions.FromJson() — Fido2NetLib built-in methods (System.Text.Json)
Serialisation (incoming requests)System.Text.Json.JsonSerializer.Deserialize<T>() — reads [JsonConverter(typeof(Base64UrlConverter))] attributes correctly; Newtonsoft removed from PasskeyController.cs
PasskeyController.Manage()return View("~/Views/Account/Passkeys.cshtml") — explicit path avoids convention mismatch
Login page hintAdded first-time user instruction below "Sign in with Passkey" button

Key rule — Newtonsoft.Json and Fido2NetLib do not mix

Use caseCorrect approachWrong approach
Sending options to browser (session + response)options.ToJson()JsonConvert.SerializeObject(options) — emits PascalCase keys; browser sees undefined
Reading options back from sessionAssertionOptions.FromJson(json) / CredentialCreateOptions.FromJson(json)JsonConvert.DeserializeObject<T>(json)
Parsing browser attestation / assertion responseJsonSerializer.Deserialize<T>(body)JsonConvert.DeserializeObject<T>(body) — uses standard Base64; rejects base64url -/_

Passkey compatibility matrix

Authenticator / flowRequires BluetoothRelated Origins checkWorks with current config
Windows Hello (PC browser)NoNo — same originVerified ✓
iPhone Safari + iCloud Keychain (iPhone browser)NoNo — same originVerified ✓
QR code → iPhone (caBLE)Yes — PC must have BT enabledNo — RP ID = www.guydev.caRequires BT on PC
MS Authenticator as iOS passkey providerNoStrict enforcementSwitch iOS provider to iCloud Keychain
www2.guydev.ca (Docker)Yes — RP ID www.guydev.ca ≠ origin www2.guydev.caNeeds review when Docker deployed