AuthenticationDemo is a .NET 10 learning project that shows how JWT access tokens and refresh tokens work together in an ASP.NET Core Web API and a Blazor Web App.
JWT authentication lets an API trust a signed token instead of keeping server-side session state for every request. An access token is short-lived and sent to protected API endpoints. A refresh token is longer-lived and used only to get a new access token when the old one expires.
Both are used because short-lived access tokens reduce the damage from token theft, while refresh tokens keep the user from logging in again every few minutes. This sample stores refresh tokens in SQL Server so they can be revoked and rotated.
AuthenticationDemo.slnx
src/
AuthenticationDemo.Api ASP.NET Core Web API, Identity, EF Core, JWT, Swagger
AuthenticationDemo.Client Blazor Web App that logs in and calls protected APIs
AuthenticationDemo.Shared DTOs shared by API and client
tests/
AuthenticationDemo.Tests Unit tests for token behavior
The API keeps authentication rules in services, database access in a repository, Identity data in EF Core, and HTTP concerns in controllers and middleware.
- .NET 10 and C# 14 for the application platform.
- ASP.NET Core Web API for authentication and protected endpoints.
- ASP.NET Core Identity for password hashing, users, and roles.
- EF Core with SQL Server LocalDB for persistence.
- JWT bearer authentication for stateless API authorization.
- Blazor Web App for the frontend sample.
- Swagger/Swashbuckle for interactive API testing with Bearer tokens.
- xUnit for focused unit tests.
sequenceDiagram
User->>API: POST /api/auth/register
API->>Identity: Create user and hash password
Identity->>DB: Save user
API-->>User: 201 Created
sequenceDiagram
User->>API: POST /api/auth/login
API->>Identity: Validate email and password
API->>DB: Save refresh token
API-->>User: Access token + refresh token
sequenceDiagram
User->>API: POST /api/auth/refresh
API->>API: Validate expired JWT signature
API->>DB: Check refresh token is active
API->>DB: Revoke old token and save replacement
API-->>User: New access token + new refresh token
sequenceDiagram
User->>API: POST /api/auth/logout
API->>DB: Revoke refresh token
API-->>User: Logged out
A JWT has three parts:
- Header: token type and signing algorithm.
- Payload: claims such as user id, email, name, roles, and expiration.
- Signature: proof that the API signed the token and it was not changed.
The API validates issuer, audience, signing key, and expiration for protected endpoints. The refresh endpoint validates the expired token signature while ignoring lifetime so it can safely read the user id.
Refresh tokens are random 64-byte values stored in the database. Each token has Expires, Created, Revoked, ReplacedByToken, IsExpired, IsRevoked, and IsActive. When refresh succeeds, the old token is revoked and a new refresh token is saved. This is refresh-token rotation.
For simplicity, the Blazor sample stores tokens in browser local storage. That is easy to inspect while learning, but it is vulnerable to XSS. A production browser app should strongly consider HttpOnly, Secure, SameSite cookies for refresh tokens.
erDiagram
ApplicationUser ||--o{ RefreshToken : owns
ApplicationUser ||--o{ AspNetUserRoles : has
AspNetRoles ||--o{ AspNetUserRoles : assigned
ApplicationUser {
string Id
string Email
string FirstName
string LastName
string PasswordHash
}
RefreshToken {
int Id
string UserId
string Token
datetime Expires
datetime Created
datetime Revoked
string ReplacedByToken
}
Request:
{
"firstName": "Demo",
"lastName": "User",
"email": "demo@example.com",
"password": "Pass123$"
}Responses: 201 Created, 400 Bad Request.
Request:
{
"email": "admin@example.com",
"password": "Pass123$"
}Response:
{
"accessToken": "jwt",
"refreshToken": "random-token",
"expiresIn": 900
}Responses: 200 OK, 401 Unauthorized.
Request:
{
"accessToken": "expired-or-current-jwt",
"refreshToken": "active-refresh-token"
}Responses: 200 OK, 401 Unauthorized.
Request:
{
"refreshToken": "active-refresh-token"
}Responses: 200 OK.
GET /api/demo/anonymous: no token required.GET /api/demo/authenticated: any authenticated user.GET /api/demo/role:UserorAdminrole.GET /api/demo/admin:Adminrole only.GET /api/demo/profile: returns current user claims.
Pages:
- Home: calls a protected endpoint and logs out.
- Register: creates a new user.
- Login: signs in and stores tokens.
- Profile: displays the current user, roles, claims, and token expiration.
- Admin: demonstrates role-based authorization.
- Unauthorized: shown when the user is not allowed.
ApiAuthenticationStateProvider reads the access token claims. AuthApiClient attaches the Bearer token, attempts refresh after 401 Unauthorized, stores the rotated tokens, and clears tokens on logout.
- Install the .NET 10 SDK and SQL Server LocalDB.
- Restore packages:
dotnet restore AuthenticationDemo.slnx- Apply the EF migration:
dotnet ef database update --project src\AuthenticationDemo.Api --startup-project src\AuthenticationDemo.Api- Run the API:
dotnet run --project src\AuthenticationDemo.Api- Run the Blazor app in another terminal:
dotnet run --project src\AuthenticationDemo.Client- Open Swagger at the API URL plus
/swagger.
Seeded users:
admin@example.com/Pass123$user@example.com/Pass123$
- Call
POST /api/auth/login. - Copy the
accessToken. - Click Swagger
Authorize. - Paste the token as the Bearer value.
- Call
GET /api/demo/authenticatedorGET /api/demo/admin. - Call
POST /api/auth/refreshwith the access token and refresh token. - Call
POST /api/auth/logoutwith the latest refresh token.
- Open the Blazor URL.
- Go to Login and use one of the seeded users.
- Open Profile to inspect the current user and token expiration.
- Open Admin. The normal user should be rejected; the admin user should succeed.
- Use Home to call a protected endpoint and logout.
- Use HTTPS for all token traffic.
- Keep access tokens short-lived.
- Store refresh tokens server-side and rotate them on every refresh.
- Revoke refresh tokens on logout.
- Generate refresh tokens with cryptographic randomness.
- Hash passwords with ASP.NET Core Identity.
- Validate JWT issuer, audience, signing key, and expiration.
- Use EF Core parameterized queries to avoid SQL injection.
- Treat local storage as educational only; XSS can expose tokens.
- Use HttpOnly, Secure, SameSite cookies when building a production browser flow.
- Configure CORS for known client origins only.
- Keep production secrets outside
appsettings.json.
401 Unauthorized: missing, expired, or invalid access token.403 Forbidden: authenticated but missing the required role.Invalid Refresh Token: token was revoked, expired, rotated, or belongs to another user.- Database migration issues: verify LocalDB is installed and the connection string is correct.
- CORS errors: ensure the Blazor URL is listed in the API CORS policy.
- Clock skew: this sample uses zero clock skew, so machine time must be accurate.
- Email verification.
- MFA.
- OAuth and OpenID Connect.
- Google, Microsoft, or GitHub login.
- HttpOnly cookie refresh-token flow.
- Redis or distributed token storage.
- Replay attack family revocation.
- OpenIddict or IdentityServer integration.