Complete record of all reported issues and resolutions · April – June 2026
Section A covers product image fixes (April 2026). Section B covers passkey / WebAuthn fixes (June 2026).
A — Product Image Fixes
B — Passkey / WebAuthn Fixes
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.
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.
| File | Change |
|---|---|
| Models/Product.cs | Added public string? ImageUrl { get; set; } |
| Data/DataSeeder.cs | Added 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.cs | Added ImageUrl to ShopProductViewModel and ProductViewModel |
| Controllers/ShopController.cs | Mapped ImageUrl = p.ImageUrl in both Index and Detail actions |
| Views/Shop/Index.cshtml | Image src falls back to p.ImageUrl before the "No Image" placeholder |
| Views/Shop/Detail.cshtml | Same fallback added |
| Areas/Admin/Views/Products/Index.cshtml | Admin thumbnail uses ImageUrl when no binary exists |
| Areas/Admin/Views/Products/_ProductForm.cshtml | Added URL image preview and ImageUrl text input field |
| Areas/Admin/Controllers/ProductsController.cs | Reads/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.
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.
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.
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.
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:
| Option | Considered? | Outcome |
|---|---|---|
| Upload real PNGs/JPEGs at runtime | No | Requires a file-upload workflow or static assets; not reproducible via migration |
| Reference external image URLs (picsum, CDN) | Yes — done in Fix 1 | Images look wrong (random real photos, not product illustrations); breaks if external URL goes offline |
| Static SVG files in wwwroot | No | Not tied to the database; lost or mismatched if DB is rebuilt without the static folder |
| SVG stored as varbinary in SQL Server via migration | Yes — chosen | Self-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.
| Product | Theme colour | Visual 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 |
| File | Change |
|---|---|
| Views/Home/Index.cshtml | Fixed image src to fall back to p.ImageUrl (matching the Shop page pattern) |
| Data/DataSeeder.cs | Backfill condition tightened: only runs when both ImageData == null AND ImageUrl == null - prevents overwriting products that already have binary images |
| Migrations/20260429080000_SeedProductImages.cs | 8 × migrationBuilder.Sql() calls - UPDATE each product with SVG binary + sets ImageUrl = NULL |
| Migrations/20260429080000_SeedProductImages.Designer.cs | EF snapshot file for the migration (schema unchanged - data-only migration) |
Down() migration (rollback): clears ImageData and ImageMimeType for all SVG-type rows.
The images are not shown in homepage and Shop page, fix it.
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.
// 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:
| Item | State |
|---|---|
| Image storage | Binary varbinary(max) in SQL Server |
| MIME type | image/svg+xml |
| Image endpoint | GET /Shop/Image?id={n} - served by ShopController.Image() |
| Response caching | 1-hour client + server cache, varied by id |
| Fallback chain | Binary upload → ImageUrl (admin-set URL) → "No Image" placeholder |
| Migrations added | 20260429060000_AddProductImageUrl (schema), 20260429080000_SeedProductImages (data) |
| Homepage | Fixed - uses same image fallback chain as Shop page |
| Admin product form | Shows current image (binary or URL), accepts new upload, allows editing ImageUrl |
| File | What changed |
|---|---|
| Models/Product.cs | Added ImageUrl property |
| Data/DataSeeder.cs | Seed image URLs + smart backfill (skips rows with binary data) |
| Models/ViewModels/ProductViewModels.cs | Added ImageUrl to both view models |
| Controllers/ShopController.cs | Maps ImageUrl into view models |
| Views/Home/Index.cshtml | Added ImageUrl fallback |
| Views/Shop/Index.cshtml | Added ImageUrl fallback |
| Views/Shop/Detail.cshtml | Added ImageUrl fallback |
| Areas/Admin/Views/Products/Index.cshtml | Admin thumbnail uses ImageUrl when no binary |
| Areas/Admin/Views/Products/_ProductForm.cshtml | URL image preview + ImageUrl text field |
| Areas/Admin/Controllers/ProductsController.cs | Handles ImageUrl in Create / Edit |
| Program.cs | AddResponseCaching() + UseResponseCaching() |
| Migrations/20260429060000_AddProductImageUrl.* | Adds ImageUrl nvarchar(max) column |
| Migrations/20260429080000_SeedProductImages.* | Seeds SVG binary images for all 8 products |
When I login by 'Sign in with Passkey', I got Cannot read properties of undefined (reading 'replace') error.
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.
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:
| Before | After |
|---|---|
| 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.
// 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");
// Before
var options = JsonConvert.DeserializeObject<AssertionOptions>(json)!;
// After
var options = AssertionOptions.FromJson(json);
// 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.
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.
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:7091 — https://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.
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.
| Environment | Files loaded | Effective Fido2 config |
|---|---|---|
| VS 2022 / dotnet run | appsettings.json → appsettings.Development.json | localhost / localhost ports |
| IIS (Cloudflare) | appsettings.json only | guydev.ca / guydev.ca origins |
"Fido2": {
"ServerDomain": "guydev.ca",
"ServerName": "GuyStore",
"Origins": "https://www.guydev.ca,https://guydev.ca"
}
"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.
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?
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.
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>
| Concept | Detail |
|---|---|
| Passkey registration | Creates the credential. Requires being already logged in. Done once per domain per device. |
| Passkey authentication | Uses an existing credential. Will fail if none has been registered for that RP ID on that device. |
| Domain binding | A passkey for localhost is entirely separate from one for guydev.ca — must re-register on every new domain. |
| Device binding | A 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 flow | Cross-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 sync | When 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. |
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).
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.
// 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.
| Item | Value |
|---|---|
| Controller | PasskeyController (route prefix /Passkey) |
| Action | Manage() — GET /Passkey/Manage |
| Convention-based path (wrong) | Views/Passkey/Manage.cshtml — does not exist |
| Actual view path (correct) | Views/Account/Passkeys.cshtml |
I got "Microsoft Authenticator doesn't support this passkey" error when trying to register a passkey.
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).
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.
| Concept | Detail |
|---|---|
| RP ID | guydev.ca — the apex domain, a suffix of www.guydev.ca |
| Related Origins check | Required when RP ID ≠ effective domain of the origin. Authenticator fetches https://{rpId}/.well-known/webauthn |
| Who enforces it | Cross-device (roaming) authenticators: Microsoft Authenticator, Android FIDO2, etc. Same-device authenticators (Windows Hello, Face ID) typically skip it |
| Without this endpoint | 404 → authenticator rejects with "doesn't support this passkey" |
| With this endpoint | 200 + JSON listing allowed origins → authenticator proceeds normally |
| Development impact | None — localhost RP ID exactly equals the localhost hostname so no Related Origins check is triggered |
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.
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.
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):
| Scenario | Detail |
|---|---|
| RP ID (production) | www.guydev.ca — now an exact match with the serving origin |
| Related Origins check | No longer triggered — RP ID equals the effective domain of https://www.guydev.ca |
| Microsoft Authenticator behavior | Does not fetch /.well-known/webauthn at all; proceeds directly with the ceremony |
| /.well-known/webauthn endpoint | Remains in place — still needed if www2.guydev.ca (Docker) uses this app as its WebAuthn backend with an apex RP ID |
| Cloudflare redirect issue | Bypassed 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 ID | Any 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.
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:
The message "iPhone needs to connect to this device first" from MS Authenticator means the BLE handshake in step 2 failed. This happens when:
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:
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.
| Scenario | Transport | Requires Bluetooth | Who handles on iPhone |
|---|---|---|---|
| PC browser → QR code → phone | FIDO2 Hybrid (caBLE) | Yes — both PC and phone | Whichever iOS passkey provider is active |
| iPhone browser → passkey | Platform (direct) | No | Whichever iOS passkey provider is active |
| PC browser → "This device" | Platform (direct) | No | Windows Hello / Windows credential manager |
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."
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.
Before — using 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";
}
| Aspect | Detail |
|---|---|
| Encoding used by browser | base64url — - and _ instead of +//, no = padding |
| Fido2NetLib byte[] converter | [JsonConverter(typeof(Base64UrlConverter))] — System.Text.Json only |
| Newtonsoft behavior | Ignores [JsonConverter] (STJ) attributes; uses standard Base64 decoder → rejects -/_ |
| System.Text.Json behavior | Reads [JsonConverter] attributes on properties; uses Base64UrlConverter → decodes correctly |
| Affected actions | CompleteRegistration and CompleteAuthentication in PasskeyController.cs |
| Rule of thumb | Any Fido2NetLib type that contains byte[] must be deserialized with System.Text.Json, never Newtonsoft |
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.
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.
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.
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:
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.
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
},
| Aspect | Before (no attachment) | After (Platform) |
|---|---|---|
| iPhone registration | Shows "Choose how to manage" provider popup | Goes directly to Face ID + iCloud Keychain |
| PC registration | Shows options incl. QR code / cross-device | Goes directly to Windows Hello |
| Sign-in (any device) | Unchanged — authentication not affected | Unchanged |
| Cross-device QR code registration | Available (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:
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 check | iOS 18 path |
|---|---|
| Enable iCloud Keychain as passkey provider | Settings → [Apple ID name at top] → iCloud → Passwords & Keychain → toggle on |
| Check which app is the active passkey provider | Settings → General → AutoFill & Passwords → Password Options → Autofill Passwords and Passkeys |
| Confirm Face ID is enrolled | Settings → 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 |
| Symptom | Likely cause | Fix |
|---|---|---|
| iOS shows QR code instead of Face ID (no iCloud option at all) | iCloud Keychain not enabled as a passkey provider | Settings → [Apple ID] → iCloud → Passwords & Keychain → enable |
| Provider popup appears but freezes when tapped | iOS deep-links to Settings while WebAuthn ceremony is still pending in Safari | Reboot iPhone; the pending ceremony timeout clears the stuck state |
| Popup shows only "Open AutoFill & Passwords" — no provider options | No passkey provider is functioning — iCloud Passwords silently broken despite appearing enabled | Sign out and back into iCloud (Part B above) |
| All settings look correct but still fails | Settings display what is registered, not what is working — iCloud can look configured but be internally stuck | iCloud re-login |
| Same popup on webauthn.io (independent site) | Confirmed device-wide provider failure — not the app's code or config | iCloud re-login |
| Face ID is set up but passkeys still don't work | Face ID (biometric sensor) and iCloud Keychain (credential store) are separate systems — both must be healthy | Verify iCloud → Passwords is syncing; re-login if unsure |
| Config / Component | Current 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:Origins | https://www.guydev.ca, https://guydev.ca, https://www2.guydev.ca |
| /.well-known/webauthn | Raw 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 hint | Added first-time user instruction below "Sign in with Passkey" button |
| Use case | Correct approach | Wrong approach |
|---|---|---|
| Sending options to browser (session + response) | options.ToJson() | JsonConvert.SerializeObject(options) — emits PascalCase keys; browser sees undefined |
| Reading options back from session | AssertionOptions.FromJson(json) / CredentialCreateOptions.FromJson(json) | JsonConvert.DeserializeObject<T>(json) |
| Parsing browser attestation / assertion response | JsonSerializer.Deserialize<T>(body) | JsonConvert.DeserializeObject<T>(body) — uses standard Base64; rejects base64url -/_ |
| Authenticator / flow | Requires Bluetooth | Related Origins check | Works with current config |
|---|---|---|---|
| Windows Hello (PC browser) | No | No — same origin | Verified ✓ |
| iPhone Safari + iCloud Keychain (iPhone browser) | No | No — same origin | Verified ✓ |
| QR code → iPhone (caBLE) | Yes — PC must have BT enabled | No — RP ID = www.guydev.ca | Requires BT on PC |
| MS Authenticator as iOS passkey provider | No | Strict enforcement | Switch iOS provider to iCloud Keychain |
| www2.guydev.ca (Docker) | — | Yes — RP ID www.guydev.ca ≠ origin www2.guydev.ca | Needs review when Docker deployed |