Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

ASP.NET Core Web API with JWT Authentication

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.

Stack

  • .NET 10
  • ASP.NET Core Web API
  • Entity Framework Core (SQL Server provider, swap as needed)
  • JWT Bearer authentication

Packages

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.0

Project structure

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

How the auth flow works

  • 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 httpOnly cookie scoped to /api/auth). The client never reads or handles this value directly - the browser sends it automatically on requests to /api/auth/*.
  • 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.

Configuration

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"));

Endpoints

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.

Register

Request:

POST /api/auth/register
{
  "username": "User",
  "password": "userpassword"
}

Response 201 Created:

{
  "id": 1,
  "username": "peter",
  "role": "Customer"
}

Login

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.

Refresh

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

Logout

Request: POST /api/auth/logout with no body.

Response: 204 No Content. Revokes the refresh token server-side and clears the cookie.

Me

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.

Testing notes

  • The access token is a plain Bearer token - attach it manually to the Authorization header 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-Cookie response. 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/refresh twice 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 with 401 and revoke all sessions for that user.
  • To verify logout: call /api/auth/logout, then /api/auth/refresh. The refresh call should fail with 401 since 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.

Known limitations / possible next steps

  • 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.

About

No description or website provided.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages