Skip to content

Commit 468f9b3

Browse files
Merge pull request #5 from dotnet-guide-com/feature/passkey-identity-sample
Add lightweight passkey identity sample
2 parents c0c14c2 + c28a4c1 commit 468f9b3

17 files changed

Lines changed: 1163 additions & 1 deletion

‎.github/workflows/build-samples.yml‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ on:
1010
- "software-architecture/architecture-testing-dotnet/**"
1111
- "distributed-systems/transactional-outbox-ef-core/**"
1212
- "aspnet-core/api-security-in-practice/**"
13+
- "aspnet-core/passkey-first-identity/**"
1314
- ".github/workflows/build-samples.yml"
1415

1516
pull_request:
@@ -20,6 +21,7 @@ on:
2021
- "software-architecture/architecture-testing-dotnet/**"
2122
- "distributed-systems/transactional-outbox-ef-core/**"
2223
- "aspnet-core/api-security-in-practice/**"
24+
- "aspnet-core/passkey-first-identity/**"
2325
- ".github/workflows/build-samples.yml"
2426

2527
workflow_dispatch:
@@ -214,3 +216,38 @@ jobs:
214216
aspnet-core/api-security-in-practice/tests/ApiSecurityMinimal.Tests/ApiSecurityMinimal.Tests.csproj
215217
--configuration Release
216218
--no-build
219+
220+
test-passkey-identity:
221+
name: Test passkey identity sample
222+
runs-on: ubuntu-latest
223+
env:
224+
ASPNETCORE_ENVIRONMENT: Testing
225+
Passkeys__ServerDomain: localhost
226+
227+
steps:
228+
- name: Check out repository
229+
uses: actions/checkout@v5
230+
231+
- name: Install .NET 10 SDK
232+
uses: actions/setup-dotnet@v5
233+
with:
234+
dotnet-version: "10.0.x"
235+
236+
- name: Restore
237+
run: >
238+
dotnet restore
239+
aspnet-core/passkey-first-identity/PasskeyIdentityMinimal.slnx
240+
241+
- name: Build
242+
run: >
243+
dotnet build
244+
aspnet-core/passkey-first-identity/PasskeyIdentityMinimal.slnx
245+
--configuration Release
246+
--no-restore
247+
248+
- name: Test server-side passkey boundaries
249+
run: >
250+
dotnet test
251+
aspnet-core/passkey-first-identity/tests/PasskeyIdentityMinimal.Tests/PasskeyIdentityMinimal.Tests.csproj
252+
--configuration Release
253+
--no-build

‎.gitignore‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ TestResults/
5151
*.coverage*
5252
*.trx
5353

54+
# Database files
55+
*.db
56+
*.db-shm
57+
*.db-wal
58+
5459
# Publish / artifacts
5560
[Bb]uild[Aa]rtifacts/
5661
artifacts/

‎README.md‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ Each sample folder contains a focused implementation of one tutorial topic. The
1515
| [`software-architecture/architecture-testing-dotnet`](software-architecture/architecture-testing-dotnet/) | Minimal NetArchTest.eNhancedEdition rule that prevents Domain from depending on outer layers | [Architecture Testing in .NET: Enforce Layer and Module Boundaries with NetArchTest and ArchUnitNET](https://www.dotnet-guide.com/tutorials/software-architecture/architecture-testing-dotnet/) |
1616
| [`distributed-systems/transactional-outbox-ef-core`](distributed-systems/transactional-outbox-ef-core/) | Minimal EF Core and SQLite demonstration that saves business state and an outbox message atomically, then publishes it through a one-shot relay | [Transactional Outbox Pattern in .NET with EF Core (.NET 10): Fix the Dual-Write Problem](https://www.dotnet-guide.com/tutorials/distributed-systems/transactional-outbox-ef-core/) |
1717
| [`aspnet-core/api-security-in-practice`](aspnet-core/api-security-in-practice/) | Minimal JWT bearer authentication, note ownership enforcement, and rate limiting for an ASP.NET Core API | [ASP.NET Core 8 API Security: JWT Authentication, CSRF Protection & Rate Limiting](https://www.dotnet-guide.com/tutorials/aspnet-core/api-security-in-practice/) |
18-
18+
| [`aspnet-core/passkey-first-identity`](aspnet-core/passkey-first-identity/) | Minimal .NET 10 Identity sample for passkey enrollment, username-first passkey sign-in, antiforgery protection, and stored credential listing | [Passkeys in ASP.NET Core Identity (.NET 10): Build a Passwordless-First Web App with WebAuthn](https://www.dotnet-guide.com/tutorials/aspnet-core/passkey-first-identity/) |
19+
|
1920
## Companion articles
2021

2122
- [Common Microsoft.Extensions.AI mistakes](https://www.dotnet-guide.com/articles/dotnet-ai/microsoft-extensions-ai-common-mistakes/)
@@ -135,6 +136,28 @@ tutorials/
135136
| `-- ApiSecurityMinimal.Tests/
136137
| |-- ApiSecurityMinimal.Tests.csproj
137138
| `-- ApiSecurityTests.cs
139+
| `-- passkey-first-identity/
140+
| |-- PasskeyIdentityMinimal.slnx
141+
| |-- README.md
142+
| |-- src/
143+
| | `-- PasskeyIdentityMinimal/
144+
| | |-- PasskeyIdentityMinimal.csproj
145+
| | |-- Program.cs
146+
| | |-- appsettings.json
147+
| | |-- Data/
148+
| | | |-- ApplicationDbContext.cs
149+
| | | `-- ApplicationUser.cs
150+
| | |-- Endpoints/
151+
| | | |-- AntiforgeryEndpointExtensions.cs
152+
| | | `-- PasskeyEndpoints.cs
153+
| | `-- wwwroot/
154+
| | |-- index.html
155+
| | `-- passkeys.js
156+
| `-- tests/
157+
| `-- PasskeyIdentityMinimal.Tests/
158+
| |-- PasskeyIdentityMinimal.Tests.csproj
159+
| |-- PasskeyEndpointTests.cs
160+
| `-- PasskeyIdentityFactory.cs
138161
|-- .github/
139162
| `-- workflows/
140163
| `-- build-samples.yml
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<Solution>
2+
<Project Path="src/PasskeyIdentityMinimal/PasskeyIdentityMinimal.csproj" />
3+
<Project Path="tests/PasskeyIdentityMinimal.Tests/PasskeyIdentityMinimal.Tests.csproj" />
4+
</Solution>
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
# Passkey-First Identity in ASP.NET Core &mdash; Minimal .NET 10 Sample
2+
3+
Full tutorial: [Passkeys in ASP.NET Core Identity (.NET 10): Build a Passwordless-First Web App with WebAuthn](https://www.dotnet-guide.com/tutorials/aspnet-core/passkey-first-identity/)
4+
5+
## What this sample demonstrates
6+
7+
- .NET 10 ASP.NET Core Identity with schema version 3
8+
- SQLite database with `EnsureCreated` schema bootstrapping
9+
- Development-only bootstrap password login
10+
- Passkey creation options via `SignInManager.MakePasskeyCreationOptionsAsync`
11+
- Browser WebAuthn `navigator.credentials.create()` with native JSON serialization
12+
- Attestation verification via `SignInManager.PerformPasskeyAttestationAsync`
13+
- Stored passkey persistence via `UserManager.AddOrUpdatePasskeyAsync`
14+
- Username-first passkey request options via `SignInManager.MakePasskeyRequestOptionsAsync`
15+
- Passkey sign-in via `SignInManager.PasskeySignInAsync`
16+
- Passkey listing via `UserManager.GetPasskeysAsync`
17+
- Antiforgery validation on every state-changing passkey endpoint
18+
- Server-side integration tests using `WebApplicationFactory`
19+
20+
## Architecture diagram
21+
22+
```
23+
Bootstrap login
24+
│
25+
▼
26+
Identity cookie
27+
│
28+
├── creation options
29+
├── browser authenticator
30+
└── verified passkey storage
31+
32+
Later sign-in
33+
│
34+
├── request options
35+
├── signed assertion
36+
└── Identity cookie
37+
```
38+
39+
## File structure
40+
41+
```
42+
aspnet-core/
43+
└── passkey-first-identity/
44+
├── PasskeyIdentityMinimal.slnx
45+
├── README.md
46+
├── src/
47+
│ └── PasskeyIdentityMinimal/
48+
│ ├── PasskeyIdentityMinimal.csproj
49+
│ ├── Program.cs
50+
│ ├── appsettings.json
51+
│ ├── Data/
52+
│ │ ├── ApplicationDbContext.cs
53+
│ │ └── ApplicationUser.cs
54+
│ ├── Endpoints/
55+
│ │ ├── AntiforgeryEndpointExtensions.cs
56+
│ │ └── PasskeyEndpoints.cs
57+
│ └── wwwroot/
58+
│ ├── index.html
59+
│ └── passkeys.js
60+
└── tests/
61+
└── PasskeyIdentityMinimal.Tests/
62+
├── PasskeyIdentityMinimal.Tests.csproj
63+
├── PasskeyEndpointTests.cs
64+
└── PasskeyIdentityFactory.cs
65+
```
66+
67+
## Prerequisites
68+
69+
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
70+
- A browser that supports WebAuthn (Chrome, Edge, Firefox, Safari)
71+
- HTTPS is required for WebAuthn in production; localhost is exempt
72+
73+
## Run instructions
74+
75+
```powershell
76+
cd aspnet-core\passkey-first-identity
77+
dotnet restore
78+
dotnet build --configuration Release --no-restore
79+
dotnet test --configuration Release --no-build
80+
dotnet run --project src\PasskeyIdentityMinimal --configuration Release --no-build
81+
```
82+
83+
On Linux/macOS:
84+
85+
```bash
86+
cd aspnet-core/passkey-first-identity
87+
ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/PasskeyIdentityMinimal --configuration Release --no-build
88+
```
89+
90+
The application starts on `https://localhost:5001` and `http://localhost:5000`.
91+
92+
## Development credentials
93+
94+
| Field | Value |
95+
|----------|------------------|
96+
| Email | demo@example.com |
97+
| Password | DemoPass123! |
98+
99+
**These are development-only credentials.** The bootstrap user exists only in Development and Testing environments. The password exists only to enroll the first passkey. Do not use these credentials in production.
100+
101+
## Manual browser test
102+
103+
1. Trust the development certificate:
104+
```bash
105+
dotnet dev-certs https --trust
106+
```
107+
108+
2. Start the application in the `Development` environment:
109+
```bash
110+
ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/PasskeyIdentityMinimal
111+
```
112+
113+
3. Open `https://localhost:5001` in your browser.
114+
115+
4. Click **Sign in with password** (the credentials are pre-filled).
116+
117+
5. After a successful sign-in, click **Create passkey**.
118+
119+
6. Complete the authenticator prompt (Windows Hello, Touch ID, browser virtual authenticator, or another supported authenticator).
120+
121+
7. Click **List my passkeys** to confirm the passkey appears.
122+
123+
8. Click **Sign out**.
124+
125+
9. Enter `demo@example.com` and click **Sign in with passkey**.
126+
127+
10. Complete the authenticator prompt.
128+
129+
11. Confirm the application shows the authenticated state.
130+
131+
### Using a browser virtual authenticator (Chrome)
132+
133+
1. Open Chrome DevTools (`F12`).
134+
2. Click **More tools** &rarr; **WebAuthn**.
135+
3. Check **Enable virtual authenticator environment**.
136+
4. Click **Add** to create a resident-key-capable authenticator.
137+
5. Complete the passkey registration and sign-in flows described above.
138+
139+
## Important boundary
140+
141+
This sample is deliberately smaller than the full tutorial. It focuses on:
142+
143+
- Bootstrap login
144+
- Passkey enrollment
145+
- Username-first passkey sign-in
146+
- Passkey listing
147+
- Schema and antiforgery correctness
148+
149+
It deliberately omits:
150+
151+
- Conditional UI and username-field autofill
152+
- Username-less (discovery) sign-in
153+
- Passkey rename and deletion
154+
- Final-factor deletion protection
155+
- Password removal
156+
- Verified-email recovery
157+
- Recovery codes
158+
- Email sending
159+
- Attestation-statement policy validation
160+
- Authenticator-model allow or deny lists (AAGUID)
161+
- FIDO Metadata Service integration
162+
- FIDO2 net library
163+
- Organization-managed security keys
164+
- IdentityServer integration
165+
- Selenium, Playwright, or browser automation
166+
- Virtual authenticator orchestration in CI
167+
- MVC or Razor Pages port
168+
- Production reverse-proxy setup
169+
- Distributed rate limiting
170+
171+
These topics remain in the full tutorial.
172+
173+
## Passkey enrollment is not a complete recovery strategy
174+
175+
Losing the only authenticator can permanently lock the user out. Production applications should:
176+
177+
- Encourage users to register at least two passkeys.
178+
- Provide verified email, recovery codes, or another approved factor.
179+
- Protect deletion of the final factor with additional checks.
180+
- Plan for account recovery before users need it.
181+
182+
This lightweight sample does not implement passkey deletion, password removal, or account recovery.
183+
184+
## Built-in attestation-policy limits
185+
186+
ASP.NET Core Identity passkeys are not a complete general-purpose WebAuthn governance stack. This sample does not provide:
187+
188+
- Attestation-statement policy
189+
- Authenticator-model allow or deny lists
190+
- FIDO Metadata Service checks
191+
- Enterprise hardware-key governance
192+
193+
Readers needing those capabilities should use the full tutorial's guidance and assess a full WebAuthn library.
194+
195+
## Verification details
196+
197+
| Item | Value |
198+
|--------------------|--------------------------------------------------------------|
199+
| Target framework | `net10.0` |
200+
| Package | Version |
201+
| `Microsoft.AspNetCore.Identity.EntityFrameworkCore` | `10.0.10` |
202+
| `Microsoft.EntityFrameworkCore.Sqlite` | `10.0.10` |
203+
| `Microsoft.EntityFrameworkCore.Design` | `10.0.10` |
204+
| `Microsoft.AspNetCore.Mvc.Testing` | `10.0.10` |
205+
| `Microsoft.NET.Test.Sdk` | `17.14.1` |
206+
| `xunit.v3` | `3.2.2` |
207+
| `xunit.runner.visualstudio` | `3.1.5` |
208+
| Database | Temporary SQLite file (`PasskeyIdentityMinimal.db` in the project directory during development; in-memory SQLite during testing) |
209+
| RP ID | `localhost` (development only; change for production) |
210+
| External services | None |
211+
| Containers | None |
212+
| API keys | None |
213+
| Hardware authenticator | Optional for manual testing |
214+
| Browser virtual authenticator | Supported for manual testing (Chrome DevTools) |
215+
| Last reviewed | 2026-07-30 |
216+
| Release build | Verified |
217+
| Tests | Verified (6/6 passing) |
218+
| Browser ceremony | Requires a WebAuthn-capable browser and authenticator; not verified on this headless build server |
219+
220+
## License
221+
222+
Sample code in this repository is available under the [MIT License](../../LICENSE).
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
2+
using Microsoft.EntityFrameworkCore;
3+
4+
namespace PasskeyIdentityMinimal.Data;
5+
6+
public sealed class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
7+
: IdentityDbContext<ApplicationUser>(options)
8+
{
9+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
using Microsoft.AspNetCore.Identity;
2+
3+
namespace PasskeyIdentityMinimal.Data;
4+
5+
public sealed class ApplicationUser : IdentityUser
6+
{
7+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using Microsoft.AspNetCore.Antiforgery;
2+
3+
namespace PasskeyIdentityMinimal.Endpoints;
4+
5+
internal static class AntiforgeryEndpointExtensions
6+
{
7+
public static TBuilder ValidateAntiforgery<TBuilder>(
8+
this TBuilder builder)
9+
where TBuilder : IEndpointConventionBuilder =>
10+
builder.AddEndpointFilter(
11+
async (context, next) =>
12+
{
13+
var antiforgery =
14+
context.HttpContext
15+
.RequestServices
16+
.GetRequiredService<IAntiforgery>();
17+
18+
try
19+
{
20+
await antiforgery
21+
.ValidateRequestAsync(
22+
context.HttpContext);
23+
}
24+
catch (
25+
AntiforgeryValidationException)
26+
{
27+
return Results.BadRequest(
28+
new
29+
{
30+
error =
31+
"Antiforgery validation failed."
32+
});
33+
}
34+
35+
return await next(context);
36+
});
37+
}

0 commit comments

Comments
 (0)