A starter template for JWT-based authentication in ASP.NET Core Web API, using short-lived access tokens (Bearer) paired with long-lived refresh tokens (httpOnly cookie) and refresh token rotation with reuse detection.
- .NET 10
- ASP.NET Core Web API
- Entity Framework Core (SQL Server provider, swap as needed)
- JWT Bearer authentication
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 10.0.12
dotnet add package Microsoft.AspNetCore.OpenApi --version 10.0.12
dotnet add package Microsoft.EntityFrameworkCore --version 10.0.12
dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 10.0.12
dotnet add package Microsoft.EntityFrameworkCore.Tools --version 10.0.12
dotnet add package Scalar.AspNetCore --version 2.17.6
dotnet add package System.IdentityModel.Tokens.Jwt --version 8.23.0Controllers/
AuthController.cs - register, login, refresh, logout
SecureController.cs - example protected endpoints
Services/
AuthService.cs - register/login/refresh/logout orchestration
TokenService.cs - access token creation, refresh token generation/hashing
UserService.cs - current-user lookup for /me
Services/Interfaces/
IAuthService.cs
ITokenService.cs
IUserService.cs
Repositories/
UserRepository.cs
RefreshTokenRepository.cs
Repositories/Interfaces/
IUserRepository.cs
IRefreshTokenRepository.cs
Models/
User.cs
RefreshToken.cs
DTOs/
Auth/RegisterRequest.cs, LoginRequest.cs, AuthResponse.cs
Users/UserResponse.cs
Options/
JwtOptions.cs
Enums/
UserRole.cs
- On login, the API issues two tokens:
- An access token (JWT, short-lived, returned in the response body). The client stores this and sends it as
Authorization: Bearer <token>on protected requests. - A refresh token (opaque random string, long-lived, set as an
httpOnlycookie scoped to/api/auth). The client never reads or handles this value directly - the browser sends it automatically on requests to/api/auth/*.
- An access token (JWT, short-lived, returned in the response body). The client stores this and sends it as
- Refresh tokens are stored server-side as a SHA-256 hash, not in plaintext. Only the hash is persisted; the raw value only ever exists in the cookie.
- On refresh, the current refresh token is revoked and a new access/refresh pair is issued (rotation). The old refresh token can no longer be used.
- If a revoked refresh token is presented again (reuse), this is treated as a sign of token theft: all refresh tokens for that user are revoked, forcing re-login on every device/session.
- Logout revokes the current refresh token and clears the cookie. It does not invalidate the access token already issued - that token remains valid until it naturally expires, since it is a stateless JWT with no server-side revocation list.
Add a Jwt section to appsettings.json:
{
"Jwt": {
"Issuer": "https://yourapi.com",
"Audience": "https://yourapi.com",
"SecretKey": "",
"AccessTokenMinutes": 15,
"RefreshTokenDays": 7
}
}Do not commit a real value for SecretKey. Set it locally with user-secrets instead:
dotnet user-secrets init
dotnet user-secrets set "Jwt:SecretKey" "a-long-random-secret-at-least-32-bytes"In production, use an environment variable or a secrets manager (Azure Key Vault, AWS Secrets Manager, etc.).
Register the options binding in Program.cs:
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Jwt"));| Method | Route | Auth required | Description |
|---|---|---|---|
| POST | /api/auth/register |
No | Creates a new user. Returns 409 Conflict if the username exists. |
| POST | /api/auth/login |
No | Verifies credentials, returns an access token, sets refresh cookie. |
| POST | /api/auth/refresh |
No (cookie) | Reads refresh token from cookie, rotates it, returns a new access token. |
| POST | /api/auth/logout |
No (cookie) | Revokes the current refresh token and clears the cookie. |
| GET | /api/secure/me |
Yes (Bearer) | Returns the authenticated user's id, username, and role. |
| GET | /api/secure/admin-only |
Yes (Bearer, Admin role) | Example role-restricted endpoint. |
Request:
POST /api/auth/register
{
"username": "User",
"password": "userpassword"
}Response 201 Created:
{
"id": 1,
"username": "peter",
"role": "Customer"
}Request:
POST /api/auth/login
{
"username": "peter",
"password": "somepassword"
}Response 200 OK (body):
{
"accessToken": "<jwt>",
"accessTokenExpiresAtUtc": "2026-09-20T09:27:37.5801435Z"
}Also sets a Set-Cookie: refresh_token=... header (httpOnly, path=/api/auth, sameSite=strict, secure outside development). This cookie is not visible in the response body and is not readable from client-side JavaScript.
Request: POST /api/auth/refresh with no body. The refresh token cookie is sent automatically by the browser as long as the request path is under /api/auth.
Response: same shape as login, with a new access token and a rotated refresh cookie.
401 Unauthorized if the cookie is missing, the token is expired, or the token was already used once (reuse detection).
Request: POST /api/auth/logout with no body.
Response: 204 No Content. Revokes the refresh token server-side and clears the cookie.
Request: GET /api/secure/me with Authorization: Bearer <accessToken>.
Response 200 OK:
{
"id": 1,
"username": "peter",
"role": "Customer"
}401 Unauthorized if the bearer token is missing, invalid, or expired.
- The access token is a plain Bearer token - attach it manually to the
Authorizationheader in whatever client you use (Postman, Scalar, curl). - The refresh token cookie is handled automatically by the browser or HTTP client's cookie jar when the API and client are on the same origin. It will not appear in the response body and cannot be read by JavaScript, by design.
- Cookie-storage panels in browser dev tools (e.g. Chrome's Application tab) do not always refresh automatically after a new
Set-Cookieresponse. If a cookie does not appear as expected, confirm via the Network tab's raw response headers before assuming the server did not send it. - To verify rotation: call
/api/auth/refreshtwice in a row. The first call should succeed with a new token pair; if the original (now-revoked) refresh token is replayed afterward, the second call should fail with401and revoke all sessions for that user. - To verify logout: call
/api/auth/logout, then/api/auth/refresh. The refresh call should fail with401since the cookie has been cleared and the underlying token revoked. Note that an access token issued before logout remains valid until it expires - logout does not revoke access tokens.
- Access tokens are not revocable before their natural expiry. Logging out does not immediately block API access with an already-issued access token.
- No rate limiting on login/register/refresh endpoints.
- No email verification or password reset flow.
- No refresh token cleanup job for expired/revoked rows (they accumulate in the database over time).
- Role handling is a single string on the user record; no support for multiple roles per user.