IdentityJWTMVCAugust 2026 · 9 min read

ASP.NET Core Identity with JWT in MVC: Access Tokens and Refresh Rotation

TL;DR

In an MVC application, ASP.NET Core Identity manages the user store while JWT bearer tokens secure the REST API. Sign short-lived access tokens, validate them with AddJwtBearer, and keep a hashed, rotating refresh token in the database so sessions can be revoked.

Classic MVC apps sign users in with cookies, and that remains the right choice for the interactive UI. But when the same application exposes a REST API for mobile clients, background workers, and third-party integrations, a cookie alone is not enough. The standard .NET 10 pattern pairs ASP.NET Core Identity with JWT: Identity owns the user record and password, and JWT bearer tokens carry the identity to every API call. This article wires both together inside one MVC application, including refresh token rotation.

The Two-Token Model: Access Token and Refresh Token

An access token is short-lived — usually 15 to 60 minutes — and carries the claims an API needs. Because it is signed, the API validates it without a database lookup. A refresh token is longer-lived and stored server-side, so it can be revoked. The client exchanges the refresh token for a new access token when the old one expires. Rotation means every exchange issues a new refresh token and invalidates the old one, so a stolen token is detected the moment it is reused.

Generating a Signed JWT Access Token

After SignInManager validates the password, build the token with the Identity claims (role and user id) and sign it with an HMAC-SHA256 key:

public static string BuildToken(AppUser user, IList<string> roles, IConfiguration config)
{
    var claims = new List<Claim>
    {
        new(JwtRegisteredClaimNames.Sub, user.Id),
        new(JwtRegisteredClaimNames.Email, user.Email),
        new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
    };
    claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));

    var key = new SymmetricSecurityKey(
        Encoding.UTF8.GetBytes(config["Jwt:Key"]));
    var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

    var token = new JwtSecurityToken(
        issuer: config["Jwt:Issuer"],
        audience: config["Jwt:Audience"],
        claims: claims,
        expires: DateTime.UtcNow.AddMinutes(30),
        signingCredentials: creds);

    return new JwtSecurityTokenHandler().WriteToken(token);
}

Role claims are included at signing time, so the API can apply [Authorize(Roles = "Admin")] purely from the token. Never put secrets or sensitive data in the token — it is only base64-encoded, not encrypted.

Validating Tokens with AddJwtBearer

The JwtBearer middleware validates issuer, audience, lifetime, and signature on every API request:

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))
        };
    });

The signing key must match exactly the key used to issue tokens, and it must live in user secrets, environment variables, or a key vault — never in appsettings.json.

Refresh Token Rotation

Store the refresh token hashed in the database, tied to the user. On each renewal, invalidate the old token and issue a new one:

public async Task<TokenResponse> RefreshAsync(string refreshToken, CancellationToken ct)
{
    var tokenHash = HashToken(refreshToken);
    var stored = await _db.RefreshTokens
        .FirstOrDefaultAsync(t => t.TokenHash == tokenHash && !t.Revoked, ct);

    if (stored is null)
        throw new SecurityException("Invalid or revoked refresh token.");

    var user = await _userManager.FindByIdAsync(stored.UserId);
    var roles = await _userManager.GetRolesAsync(user);

    stored.Revoked = true;                       // rotate: kill the old token
    var newToken = new RefreshToken
    {
        UserId = user.Id,
        TokenHash = HashToken(Guid.NewGuid().ToString("N")),
        ExpiresUtc = DateTime.UtcNow.AddDays(7)
    };
    _db.RefreshTokens.Add(newToken);
    await _db.SaveChangesAsync(ct);

    return new TokenResponse(BuildToken(user, roles, _config), newToken.TokenHash);
}

Hashing the stored token means a database leak does not expose usable refresh tokens. If an attacker reuses a rotated token, the stored record is already revoked and the attempt is flagged.

Sharing One Identity Across the MVC UI and the API

Both schemes can run side by side. The MVC UI uses the cookie scheme for page access, and API controllers use JWT. A combined login action signs the cookie for the UI and returns a token pair for API clients. The Identity user store is shared, so one user works everywhere. This is precisely how the MVC EDevKit Basic starter wires Identity, JWT, and its REST endpoints together.

Key Takeaways

  • Identity manages users and passwords; JWT bearer tokens authorize API requests
  • Access tokens are short-lived and signed; refresh tokens are long-lived and revocable
  • Rotate refresh tokens on every renewal and store them hashed in the database
  • Keep the signing key out of source control
  • Cookie auth for the MVC UI and JWT for the API can share one Identity store

FAQ

Why not just use cookies for everything? Cookies work for same-origin browsers but not for mobile apps, server-to-server calls, or third-party clients. JWT gives those consumers a bearer credential that does not depend on browser storage.

How short should an access token live? Fifteen to sixty minutes is standard. The shorter the lifetime, the smaller the window a stolen token is usable; the refresh token re-issues it.

Should refresh tokens be stored in the database? Yes, hashed. Server-side storage is what makes revocation possible, and hashing protects the value if the database leaks.

Can MVC views and API endpoints share the same role claims? Yes, both the cookie principal and the JWT principal carry the same Identity role claims, so [Authorize(Roles = "Admin")] works identically on pages and endpoints.

Study Identity + JWT wired into real MVC source code?

MVC EDevKit Basic ships Identity with JWT access tokens, refresh token rotation, roles, and Firebase SSO across a full .NET 10 MVC application. $21.

View MVC EDevKit Details