Skip to content

Commit 2f7dd5a

Browse files
Merge pull request #4 from dotnet-guide-com/feature/api-security-sample
Add lightweight API security sample
2 parents 6bb690b + 6026802 commit 2f7dd5a

12 files changed

Lines changed: 800 additions & 0 deletions

File tree

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ on:
99
- "dotnet-aspire/orchestrate-distributed-system/**"
1010
- "software-architecture/architecture-testing-dotnet/**"
1111
- "distributed-systems/transactional-outbox-ef-core/**"
12+
- "aspnet-core/api-security-in-practice/**"
1213
- ".github/workflows/build-samples.yml"
1314

1415
pull_request:
@@ -18,6 +19,7 @@ on:
1819
- "dotnet-aspire/orchestrate-distributed-system/**"
1920
- "software-architecture/architecture-testing-dotnet/**"
2021
- "distributed-systems/transactional-outbox-ef-core/**"
22+
- "aspnet-core/api-security-in-practice/**"
2123
- ".github/workflows/build-samples.yml"
2224

2325
workflow_dispatch:
@@ -178,3 +180,37 @@ jobs:
178180
--project distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/TransactionalOutboxMinimal.csproj
179181
--configuration Release
180182
--no-build
183+
184+
test-api-security:
185+
name: Test API security sample
186+
runs-on: ubuntu-latest
187+
env:
188+
Jwt__Key: "CI-only-test-key-at-least-32-bytes-long"
189+
190+
steps:
191+
- name: Check out repository
192+
uses: actions/checkout@v5
193+
194+
- name: Install .NET 10 SDK
195+
uses: actions/setup-dotnet@v5
196+
with:
197+
dotnet-version: "10.0.x"
198+
199+
- name: Restore
200+
run: >
201+
dotnet restore
202+
aspnet-core/api-security-in-practice/ApiSecurityMinimal.slnx
203+
204+
- name: Build
205+
run: >
206+
dotnet build
207+
aspnet-core/api-security-in-practice/ApiSecurityMinimal.slnx
208+
--configuration Release
209+
--no-restore
210+
211+
- name: Test API security
212+
run: >
213+
dotnet test
214+
aspnet-core/api-security-in-practice/tests/ApiSecurityMinimal.Tests/ApiSecurityMinimal.Tests.csproj
215+
--configuration Release
216+
--no-build

‎README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Each sample folder contains a focused implementation of one tutorial topic. The
1414
| [`dotnet-aspire/orchestrate-distributed-system`](dotnet-aspire/orchestrate-distributed-system/) | Minimal Aspire AppHost coordinating a web project and API with service discovery and startup ordering | [Aspire in .NET: Orchestrate, Run, and Deploy a Distributed System from One App Host](https://www.dotnet-guide.com/tutorials/dotnet-aspire/orchestrate-distributed-system/) |
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/) |
17+
| [`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/) |
1718
|
1819
## Companion articles
1920

@@ -115,6 +116,22 @@ tutorials/
115116
| `-- TransactionalOutboxMinimal.Tests/
116117
| |-- TransactionalOutboxMinimal.Tests.csproj
117118
| `-- OutboxFlowTests.cs
119+
|-- aspnet-core/
120+
| `-- api-security-in-practice/
121+
| |-- ApiSecurityMinimal.slnx
122+
| |-- README.md
123+
| |-- src/
124+
| | `-- ApiSecurityMinimal/
125+
| | |-- ApiSecurityMinimal.csproj
126+
| | |-- Program.cs
127+
| | |-- Models.cs
128+
| | |-- DemoStore.cs
129+
| | |-- JwtTokenService.cs
130+
| | `-- appsettings.json
131+
| `-- tests/
132+
| `-- ApiSecurityMinimal.Tests/
133+
| |-- ApiSecurityMinimal.Tests.csproj
134+
| `-- ApiSecurityTests.cs
118135
|-- .github/
119136
| `-- workflows/
120137
| `-- 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/ApiSecurityMinimal/ApiSecurityMinimal.csproj" />
3+
<Project Path="tests/ApiSecurityMinimal.Tests/ApiSecurityMinimal.Tests.csproj" />
4+
</Solution>
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# ASP.NET Core API Security &mdash; Minimal JWT and Rate Limiting Sample
2+
3+
Full tutorial: [ASP.NET Core 8 API Security: JWT Authentication, CSRF Protection & Rate Limiting](https://www.dotnet-guide.com/tutorials/aspnet-core/api-security-in-practice/)
4+
5+
## What this sample demonstrates
6+
7+
- .NET 10 ASP.NET Core Minimal API
8+
- JWT bearer authentication with issuer, audience, lifetime, signing-key, and algorithm validation
9+
- Short-lived tokens (30 minutes)
10+
- Development-only login endpoint (`POST /auth/token`)
11+
- One seeded demo user
12+
- One protected Notes resource with ownership checks
13+
- 404 for another user's hidden note (anti-enumeration)
14+
- Login rate limiting (5 requests/minute)
15+
- Per-user API rate limiting (20 requests/minute)
16+
- JSON 401 and 429 responses following RFC-style
17+
- Integration tests using `WebApplicationFactory`
18+
19+
## Architecture
20+
21+
```
22+
Client
23+
|
24+
|-- POST /auth/token
25+
| |
26+
| +-- signed JWT
27+
|
28+
+-- Authorization: Bearer <token>
29+
|
30+
v
31+
Protected Notes API
32+
|
33+
|-- ownership check
34+
+-- per-user rate limit
35+
```
36+
37+
## File structure
38+
39+
```
40+
aspnet-core/
41+
+-- api-security-in-practice/
42+
|-- ApiSecurityMinimal.slnx
43+
|-- README.md
44+
|-- src/
45+
| +-- ApiSecurityMinimal/
46+
| |-- ApiSecurityMinimal.csproj
47+
| |-- Program.cs
48+
| |-- Models.cs
49+
| |-- DemoStore.cs
50+
| |-- JwtTokenService.cs
51+
| +-- appsettings.json
52+
+-- tests/
53+
+-- ApiSecurityMinimal.Tests/
54+
|-- ApiSecurityMinimal.Tests.csproj
55+
+-- ApiSecurityTests.cs
56+
```
57+
58+
## Prerequisites
59+
60+
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
61+
62+
No database, Docker, or external service is required.
63+
64+
## Demo credentials
65+
66+
| Field | Value |
67+
| --- | --- |
68+
| Email | `demo@example.com` |
69+
| Password | `DemoPass123!` |
70+
71+
These are development-only credentials. Production systems must use ASP.NET Core Identity or a proper password hasher.
72+
73+
## Setup
74+
75+
Set a JWT signing key using a method that does not commit the key to source control.
76+
77+
### Environment variable (recommended for CI / quick testing)
78+
79+
```powershell
80+
$env:Jwt__Key = "<development-key-at-least-32-bytes>"
81+
```
82+
83+
### User secrets (recommended for local development)
84+
85+
```powershell
86+
dotnet user-secrets init --project src/ApiSecurityMinimal
87+
dotnet user-secrets set "Jwt:Key" "<development-key-at-least-32-bytes>" --project src/ApiSecurityMinimal
88+
```
89+
90+
## Run
91+
92+
```powershell
93+
cd aspnet-core\api-security-in-practice
94+
95+
dotnet restore
96+
dotnet build --configuration Release --no-restore
97+
98+
# Run tests
99+
dotnet test --configuration Release --no-build
100+
101+
# Run the API
102+
$env:Jwt__Key = "<development-key-at-least-32-bytes>"
103+
dotnet run --project src\ApiSecurityMinimal --configuration Release --no-build
104+
```
105+
106+
### Manual API test
107+
108+
```powershell
109+
# Login
110+
curl.exe -X POST "http://localhost:<port>/auth/token" `
111+
-H "Content-Type: application/json" `
112+
-d "{\"email\":\"demo@example.com\",\"password\":\"DemoPass123!\"}"
113+
114+
# Use the returned token for protected endpoints
115+
curl.exe "http://localhost:<port>/notes" `
116+
-H "Authorization: Bearer <token>"
117+
```
118+
119+
The actual local port is shown by `dotnet run` and varies per run.
120+
121+
## Verify
122+
123+
| Request | Expected result |
124+
| --- | --- |
125+
| `GET /health` (anonymous) | 200 `{"status":"healthy"}` |
126+
| `POST /auth/token` with valid credentials | 200 + JWT |
127+
| `POST /auth/token` with invalid credentials | 401 |
128+
| `GET /notes` without token | 401 |
129+
| `GET /notes` with valid token | 200 + notes (initially empty) |
130+
| `DELETE /notes/{id}` owned by another user | 404 |
131+
| 6th login attempt within a minute | 429 + `Retry-After` |
132+
133+
## Important boundary
134+
135+
This is an **educational JWT-only sample**, not a complete identity system. It intentionally omits:
136+
137+
- Cookie authentication
138+
- CSRF tokens and antiforgery middleware
139+
- CORS configuration
140+
- HSTS and browser security headers
141+
- ASP.NET Core Identity
142+
- Password hashing (plain-text demo password)
143+
- Refresh tokens and token revocation
144+
- Persistent database
145+
- Distributed rate limiting
146+
- OAuth 2.0 / OpenID Connect
147+
- Production deployment
148+
149+
The full tutorial remains the authoritative source for all of the above.
150+
151+
## Version note
152+
153+
The tutorial is written around **ASP.NET Core 8**. The companion sample targets **.NET 10** while using the same core authentication, authorization, and rate-limiting concepts.
154+
155+
## Security disclosures
156+
157+
- The demo password is intentionally simple and local-only
158+
- Production systems should use ASP.NET Core Identity or an external identity provider
159+
- Production signing keys belong in a secret manager
160+
- Access tokens should be short-lived
161+
- Refresh-token rotation and revocation are not implemented
162+
- Cookie authentication and CSRF protection are intentionally excluded
163+
- CORS and browser security headers are intentionally excluded
164+
- Rate limiting is in-process and not distributed across instances
165+
- The in-memory store resets on restart
166+
167+
## Verification details
168+
169+
| Item | Value |
170+
| --- | --- |
171+
| Target framework | `net10.0` |
172+
| `Microsoft.AspNetCore.Authentication.JwtBearer` | 10.0.10 |
173+
| `Microsoft.AspNetCore.Mvc.Testing` | 10.0.10 |
174+
| `xunit.v3` | 3.2.2 |
175+
| `xunit.runner.visualstudio` | 3.1.5 |
176+
| `Microsoft.NET.Test.Sdk` | 17.14.1 |
177+
| External services required | None |
178+
| Containers required | None |
179+
| API keys required | None |
180+
| Runtime secret required | Development JWT signing key |
181+
| Last reviewed | July 28, 2026 |
182+
| Release build | Verified |
183+
| Tests | Verified (6/6 passing) |
184+
185+
## License
186+
187+
Sample code in this folder is available under the [MIT License](../LICENSE).
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<UserSecretsId>6e2e3e9a-7c4b-4f1a-9d8b-3c5f2a1e8d7b</UserSecretsId>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
12+
</ItemGroup>
13+
14+
</Project>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using System.Collections.Concurrent;
2+
3+
namespace ApiSecurityMinimal;
4+
5+
public sealed class DemoStore
6+
{
7+
private readonly ConcurrentDictionary<string, DemoUser> _users = new();
8+
private readonly ConcurrentDictionary<string, Note> _notes = new();
9+
private int _nextNoteNumber;
10+
11+
public DemoStore()
12+
{
13+
Seed();
14+
}
15+
16+
private void Seed()
17+
{
18+
var user = new DemoUser(
19+
Id: "user-001",
20+
Email: "demo@example.com",
21+
Password: "DemoPass123!",
22+
Role: "User");
23+
_users.TryAdd(user.Email, user);
24+
}
25+
26+
public DemoUser? FindUserByEmail(string email) =>
27+
_users.TryGetValue(email, out var user) ? user : null;
28+
29+
public Note CreateNote(string ownerId, string title, string body)
30+
{
31+
int number = Interlocked.Increment(ref _nextNoteNumber);
32+
var note = new Note(
33+
Id: $"note-{number:D4}",
34+
OwnerId: ownerId,
35+
Title: title,
36+
Body: body);
37+
_notes.TryAdd(note.Id, note);
38+
return note;
39+
}
40+
41+
public IEnumerable<Note> GetNotesByOwner(string ownerId) =>
42+
_notes.Values.Where(n => n.OwnerId == ownerId);
43+
44+
public Note? FindNote(string noteId) =>
45+
_notes.TryGetValue(noteId, out var note) ? note : null;
46+
47+
public bool DeleteNote(string noteId, string ownerId)
48+
{
49+
if (!_notes.TryGetValue(noteId, out var note))
50+
return false;
51+
52+
if (note.OwnerId != ownerId)
53+
return false;
54+
55+
return _notes.TryRemove(noteId, out _);
56+
}
57+
}

0 commit comments

Comments
 (0)