Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/build-samples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
- "dotnet-aspire/orchestrate-distributed-system/**"
- "software-architecture/architecture-testing-dotnet/**"
- "distributed-systems/transactional-outbox-ef-core/**"
- "aspnet-core/api-security-in-practice/**"
- ".github/workflows/build-samples.yml"

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

workflow_dispatch:
Expand Down Expand Up @@ -178,3 +180,37 @@ jobs:
--project distributed-systems/transactional-outbox-ef-core/src/TransactionalOutboxMinimal/TransactionalOutboxMinimal.csproj
--configuration Release
--no-build

test-api-security:
name: Test API security sample
runs-on: ubuntu-latest
env:
Jwt__Key: "CI-only-test-key-at-least-32-bytes-long"

steps:
- name: Check out repository
uses: actions/checkout@v5

- name: Install .NET 10 SDK
uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.0.x"

- name: Restore
run: >
dotnet restore
aspnet-core/api-security-in-practice/ApiSecurityMinimal.slnx

- name: Build
run: >
dotnet build
aspnet-core/api-security-in-practice/ApiSecurityMinimal.slnx
--configuration Release
--no-restore

- name: Test API security
run: >
dotnet test
aspnet-core/api-security-in-practice/tests/ApiSecurityMinimal.Tests/ApiSecurityMinimal.Tests.csproj
--configuration Release
--no-build
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Each sample folder contains a focused implementation of one tutorial topic. The
| [`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/) |
| [`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/) |
| [`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/) |
| [`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/) |
|
## Companion articles

Expand Down Expand Up @@ -115,6 +116,22 @@ tutorials/
| `-- TransactionalOutboxMinimal.Tests/
| |-- TransactionalOutboxMinimal.Tests.csproj
| `-- OutboxFlowTests.cs
|-- aspnet-core/
| `-- api-security-in-practice/
| |-- ApiSecurityMinimal.slnx
| |-- README.md
| |-- src/
| | `-- ApiSecurityMinimal/
| | |-- ApiSecurityMinimal.csproj
| | |-- Program.cs
| | |-- Models.cs
| | |-- DemoStore.cs
| | |-- JwtTokenService.cs
| | `-- appsettings.json
| `-- tests/
| `-- ApiSecurityMinimal.Tests/
| |-- ApiSecurityMinimal.Tests.csproj
| `-- ApiSecurityTests.cs
|-- .github/
| `-- workflows/
| `-- build-samples.yml
Expand Down
4 changes: 4 additions & 0 deletions aspnet-core/api-security-in-practice/ApiSecurityMinimal.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<Solution>
<Project Path="src/ApiSecurityMinimal/ApiSecurityMinimal.csproj" />
<Project Path="tests/ApiSecurityMinimal.Tests/ApiSecurityMinimal.Tests.csproj" />
</Solution>
187 changes: 187 additions & 0 deletions aspnet-core/api-security-in-practice/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# ASP.NET Core API Security &mdash; Minimal JWT and Rate Limiting Sample

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/)

## What this sample demonstrates

- .NET 10 ASP.NET Core Minimal API
- JWT bearer authentication with issuer, audience, lifetime, signing-key, and algorithm validation
- Short-lived tokens (30 minutes)
- Development-only login endpoint (`POST /auth/token`)
- One seeded demo user
- One protected Notes resource with ownership checks
- 404 for another user's hidden note (anti-enumeration)
- Login rate limiting (5 requests/minute)
- Per-user API rate limiting (20 requests/minute)
- JSON 401 and 429 responses following RFC-style
- Integration tests using `WebApplicationFactory`

## Architecture

```
Client
|
|-- POST /auth/token
| |
| +-- signed JWT
|
+-- Authorization: Bearer <token>
|
v
Protected Notes API
|
|-- ownership check
+-- per-user rate limit
```

## File structure

```
aspnet-core/
+-- api-security-in-practice/
|-- ApiSecurityMinimal.slnx
|-- README.md
|-- src/
| +-- ApiSecurityMinimal/
| |-- ApiSecurityMinimal.csproj
| |-- Program.cs
| |-- Models.cs
| |-- DemoStore.cs
| |-- JwtTokenService.cs
| +-- appsettings.json
+-- tests/
+-- ApiSecurityMinimal.Tests/
|-- ApiSecurityMinimal.Tests.csproj
+-- ApiSecurityTests.cs
```

## Prerequisites

- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)

No database, Docker, or external service is required.

## Demo credentials

| Field | Value |
| --- | --- |
| Email | `demo@example.com` |
| Password | `DemoPass123!` |

These are development-only credentials. Production systems must use ASP.NET Core Identity or a proper password hasher.

## Setup

Set a JWT signing key using a method that does not commit the key to source control.

### Environment variable (recommended for CI / quick testing)

```powershell
$env:Jwt__Key = "<development-key-at-least-32-bytes>"
```

### User secrets (recommended for local development)

```powershell
dotnet user-secrets init --project src/ApiSecurityMinimal
dotnet user-secrets set "Jwt:Key" "<development-key-at-least-32-bytes>" --project src/ApiSecurityMinimal
```

## Run

```powershell
cd aspnet-core\api-security-in-practice

dotnet restore
dotnet build --configuration Release --no-restore

# Run tests
dotnet test --configuration Release --no-build

# Run the API
$env:Jwt__Key = "<development-key-at-least-32-bytes>"
dotnet run --project src\ApiSecurityMinimal --configuration Release --no-build
```

### Manual API test

```powershell
# Login
curl.exe -X POST "http://localhost:<port>/auth/token" `
-H "Content-Type: application/json" `
-d "{\"email\":\"demo@example.com\",\"password\":\"DemoPass123!\"}"

# Use the returned token for protected endpoints
curl.exe "http://localhost:<port>/notes" `
-H "Authorization: Bearer <token>"
```

The actual local port is shown by `dotnet run` and varies per run.

## Verify

| Request | Expected result |
| --- | --- |
| `GET /health` (anonymous) | 200 `{"status":"healthy"}` |
| `POST /auth/token` with valid credentials | 200 + JWT |
| `POST /auth/token` with invalid credentials | 401 |
| `GET /notes` without token | 401 |
| `GET /notes` with valid token | 200 + notes (initially empty) |
| `DELETE /notes/{id}` owned by another user | 404 |
| 6th login attempt within a minute | 429 + `Retry-After` |

## Important boundary

This is an **educational JWT-only sample**, not a complete identity system. It intentionally omits:

- Cookie authentication
- CSRF tokens and antiforgery middleware
- CORS configuration
- HSTS and browser security headers
- ASP.NET Core Identity
- Password hashing (plain-text demo password)
- Refresh tokens and token revocation
- Persistent database
- Distributed rate limiting
- OAuth 2.0 / OpenID Connect
- Production deployment

The full tutorial remains the authoritative source for all of the above.

## Version note

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.

## Security disclosures

- The demo password is intentionally simple and local-only
- Production systems should use ASP.NET Core Identity or an external identity provider
- Production signing keys belong in a secret manager
- Access tokens should be short-lived
- Refresh-token rotation and revocation are not implemented
- Cookie authentication and CSRF protection are intentionally excluded
- CORS and browser security headers are intentionally excluded
- Rate limiting is in-process and not distributed across instances
- The in-memory store resets on restart

## Verification details

| Item | Value |
| --- | --- |
| Target framework | `net10.0` |
| `Microsoft.AspNetCore.Authentication.JwtBearer` | 10.0.10 |
| `Microsoft.AspNetCore.Mvc.Testing` | 10.0.10 |
| `xunit.v3` | 3.2.2 |
| `xunit.runner.visualstudio` | 3.1.5 |
| `Microsoft.NET.Test.Sdk` | 17.14.1 |
| External services required | None |
| Containers required | None |
| API keys required | None |
| Runtime secret required | Development JWT signing key |
| Last reviewed | July 28, 2026 |
| Release build | Verified |
| Tests | Verified (6/6 passing) |

## License

Sample code in this folder is available under the [MIT License](../LICENSE).
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>6e2e3e9a-7c4b-4f1a-9d8b-3c5f2a1e8d7b</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Collections.Concurrent;

namespace ApiSecurityMinimal;

public sealed class DemoStore
{
private readonly ConcurrentDictionary<string, DemoUser> _users = new();
private readonly ConcurrentDictionary<string, Note> _notes = new();
private int _nextNoteNumber;

public DemoStore()
{
Seed();
}

private void Seed()
{
var user = new DemoUser(
Id: "user-001",
Email: "demo@example.com",
Password: "DemoPass123!",
Role: "User");
_users.TryAdd(user.Email, user);
}

public DemoUser? FindUserByEmail(string email) =>
_users.TryGetValue(email, out var user) ? user : null;

public Note CreateNote(string ownerId, string title, string body)
{
int number = Interlocked.Increment(ref _nextNoteNumber);
var note = new Note(
Id: $"note-{number:D4}",
OwnerId: ownerId,
Title: title,
Body: body);
_notes.TryAdd(note.Id, note);
return note;
}

public IEnumerable<Note> GetNotesByOwner(string ownerId) =>
_notes.Values.Where(n => n.OwnerId == ownerId);

public Note? FindNote(string noteId) =>
_notes.TryGetValue(noteId, out var note) ? note : null;

public bool DeleteNote(string noteId, string ownerId)
{
if (!_notes.TryGetValue(noteId, out var note))
return false;

if (note.OwnerId != ownerId)
return false;

return _notes.TryRemove(noteId, out _);
}
}
Loading
Loading