Welcome to the first lesson of our course on securing your REST API application with C# in ASP.NET Core. In this module, we will move beyond basic JWT authentication and focus on implementing, managing, and rotating refresh tokens. This mechanism is the industry standard for balancing high security with a seamless user experience.
In a stateless authentication system, refresh tokens act as long-lived credentials that allow clients to obtain new access tokens without forcing the user to re-enter their password.
To understand their necessity, we must look at the limitations of a standalone access token:
- Access tokens are short-lived (typically 5–15 minutes) to minimize risk if stolen.
- Once an access token expires, the user is effectively logged out.
- Asking the user to log in every 15 minutes creates a terrible user experience.
Refresh tokens solve this by having a lifespan of days or weeks. They are used strictly to negotiate new access tokens. This separation of concerns allows us to keep access tokens short-lived and secure, while maintaining long-running user sessions.
The two token types have different storage strategies on the client and server sides:
- Access tokens are stateless JWTs. They are stored only on the client (typically in memory or local storage) and are never persisted on the server. The server validates them purely by checking their cryptographic signature. This makes them fast to verify but impossible to revoke before they expire.
- Refresh tokens are stored on both sides. The server persists them in a database, which enables validation, revocation, and rotation. The client stores them locally (e.g., in an HTTP-only cookie or secure storage) so it can present them when requesting a new access token.
This dual-storage design is critical: because refresh tokens exist in the server's database, the server can revoke them at any time — something it cannot do with a stateless JWT. This is what gives the refresh token architecture its security advantage.
Implementing this dual-token architecture introduces new dynamics to your application.
Advantages:
- Enhanced Security: Since access tokens expire quickly, the window of opportunity for an attacker using a stolen access token is very small.
- Revocation Capabilities: Unlike JWTs, which cannot be easily revoked before expiry, refresh tokens are stored in a database. You can instantly revoke a user's access by marking their refresh token as revoked.
- Seamless Experience: Users remain authenticated for long periods without repeated login prompts.
Disadvantages:
- Increased Complexity: The client must handle token expiration and perform background refreshes.
- Storage Requirements: Unlike stateless JWTs, refresh tokens require server-side storage and state management.
- Attack Surface: If a refresh token is stolen, an attacker could potentially generate new access tokens until the refresh token expires.
To mitigate the risk of theft, we employ a technique called token rotation.
Refresh token rotation is a security protocol where every time a refresh token is used, it is invalidated and replaced.
- Single Use: A refresh token is valid for exactly one exchange.
- The Exchange: When the client requests a new access token, the server issues a new access token AND a new refresh token.
- Invalidation: The old refresh token is immediately marked as revoked.
Why is this secure? If an attacker manages to steal a refresh token, they race the legitimate user to use it.
- If the attacker uses it first, the legitimate user's next attempt will fail (because the token is revoked), forcing them to log in again. This "sudden logout" alerts the user (and potentially the system) that something is wrong.
- If the user uses it first, the attacker's stolen token becomes useless immediately.
Token Reuse Detection: By keeping revoked tokens in the database (marked with IsRevoked = true) rather than deleting them, the server can detect when someone attempts to reuse an already-rotated token. If a revoked token is presented, the server knows a theft has likely occurred and can take defensive action, such as revoking all tokens for that user. We will implement this detection in our rotation endpoint.
This rotation creates a moving target, significantly limiting the damage a compromised token can cause.
The following diagram illustrates the lifecycle of a token refresh request.
The Process Steps:
- Request: The client sends the expired access token (optional, depending on implementation) and the valid
refreshToken₀to the/refreshendpoint. - Verification: The API checks the database. If the token exists and hasn't been revoked or expired, it proceeds.
- Rotation (Critical Step): The API marks
refreshToken₀as revoked. It is no longer valid. - Issuance: The API generates
accessToken₁andrefreshToken₁. - Persistence:
refreshToken₁is saved to the database. - Response: The client receives the new pair and updates its local storage.
This flow ensures that the database always reflects the single most recent valid token for that session.

To implement this, we first need a model to represent the token in our database. We use Entity Framework Core to define the schema.
We use a separate Guid Id as the primary key, following Entity Framework Core conventions (EF Core automatically treats a property named Id as the primary key). The Token string is stored as a separate unique-indexed column that we query against. This separation keeps the primary key stable and predictable while allowing the token value itself to be any format we choose.
The IsRevoked flag is central to our rotation strategy: instead of deleting used tokens, we mark them as revoked. This lets us detect token reuse — if someone presents a token that is already revoked, we know a theft may have occurred. The CreatedAtUtc field records when the token was issued, which is useful for auditing and for background cleanup jobs that periodically remove old revoked tokens.
Next, we register the model in our AppDbContext. This allows us to perform CRUD operations on the tokens. We use the Fluent API in OnModelCreating to configure indexes and relationships.
The unique index on Token ensures fast lookups when a client presents a refresh token, and prevents duplicate token values from being stored.
The OnDelete(DeleteBehavior.Cascade) configuration tells the database to automatically delete all of a user's refresh tokens when that user's row is removed from the Users table. This is triggered by the database engine itself when a DELETE statement hits the parent row. Without it, you would be left with "orphan" tokens — refresh tokens pointing to a UserId that no longer exists. Cascade delete is appropriate here because a refresh token has no meaning without its associated user. In scenarios where you need to preserve historical records (e.g., audit logs), you would choose DeleteBehavior.Restrict or DeleteBehavior.SetNull instead.
We need a dedicated service to handle the creation of token values. This service separates the concern of generating cryptography from the concern of handling HTTP requests and database operations.
First, we define a configuration class to hold our token lifetimes. Centralizing these values makes them easy to find and update.
Next, we implement the TokenService class. It provides two methods: one to generate a signed JWT access token, and one to generate a cryptographically secure refresh token string.
Notice that GenerateAccessToken is an instance method because it requires the signing key (stored in _key). In contrast, GenerateRefreshToken is because it does not depend on any instance state — it simply produces a random string. This distinction keeps the API honest about what each method needs to function.
To make the service functional, we must register it and the database context in our application container in Program.cs. We use SQLite as our database and register TokenService as a singleton — since it only holds a signing key and has no mutable state, a single instance is safe to share across all requests.
Note: In the examples for this course, we hardcode the JWT secret directly in
Program.csfor simplicity. In a production application, you should store secrets in environment variables, Azure Key Vault, or the ASP.NET Core Secret Manager — never in source code.
We also need a simple DTO record for the response that our endpoints will return:
Now that our infrastructure is in place, we can build the logic that clients will interact with.
Before implementing endpoints, we define a reusable helper function that orchestrates the full process of issuing a new token pair. This function is called during both login and token refresh, ensuring consistent behavior throughout the application.
The function performs several operations in sequence: look up the user, generate both tokens, revoke any existing tokens for that user, persist the new token, and return the pair.
By marking all existing tokens as revoked before adding a new one (step 3), we enforce a strict policy where a user is only allowed one valid session at a time. This is a design choice; some applications allow multiple concurrent sessions (e.g., one on mobile, one on desktop), but for this course, we prioritize strict security control.
This helper function is defined as a static local function in Program.cs, which is idiomatic for Minimal API applications. It keeps the token-creation logic in one place rather than duplicating it across every endpoint that needs to issue tokens.
Using Minimal APIs, we define the /api/auth/refresh endpoint directly in Program.cs. This endpoint validates the incoming refresh token, checks for reuse attacks, performs rotation, and issues a new token pair.
This endpoint implements two critical security mechanisms:
- Token Rotation (Step 5): The old token is marked as revoked (
IsRevoked = true) before the new one is issued. If the process fails halfway, the user loses their session — this is a "fail-safe" design. It is better to accidentally log a user out than to leave a compromised token active. - Token Reuse Detection (Step 3): Because we keep revoked tokens in the database instead of deleting them, we can detect when a previously-used token is presented again. This is a strong signal that the token was stolen — either the attacker or the legitimate user is replaying an old token. In response, we revoke tokens for that user, forcing everyone (including the attacker) to re-authenticate.
When a client successfully calls this endpoint, they will receive a JSON payload containing the new credentials:
The client must now discard their old tokens and replace them with these new values for all subsequent requests.
In this lesson, we established the foundation for a robust authentication system using refresh token rotation. You learned how to model the token in C#, configure the database context, and implement the logic to swap an old token for a new one securely.
The key takeaways are:
- Refresh tokens live in the database (with
IsRevokedandCreatedAtUtctracking), while access tokens are stateless JWTs. - Rotation marks the old token as revoked immediately upon use, rather than deleting it.
- Token reuse detection leverages revoked tokens to identify potential theft and revoke all sessions for the affected user.
- The
CreateAuthTokensAsynchelper encapsulates token generation and database operations for consistent use across login, registration, and refresh flows.
In the upcoming practice exercises, you will write the code to integrate this service into a functional API and handle edge cases where token rotation fails. Prepare to get your hands dirty with Entity Framework Core and JWT logic!
