Asp.Net Core Security
OAuth ( Open Authorization )
Created: 25 Jan 2026
Updated: 25 Jan 2026
What is Reference Token? Server-Side Token Management with .NET
Table of Contents
- Introduction
- JWT vs Reference Token
- Reference Token Architecture
- Step-by-Step Implementation
- Security Best Practices
- Performance Optimizations
- Testing
- Monitoring and Logging
- Comparison Table
- Conclusion
Introduction
Modern web applications typically use JWT (JSON Web Token) for authentication. However, JWT has several disadvantages:
- Cannot instantly revoke tokens (token remains valid until expiration)
- Token content is exposed to the client (readable via base64 decode)
- Large token size creates network overhead
- Difficult to update sensitive information (must renew token if roles/permissions change)
Reference Token solves these problems:
Token information is stored server-side, client only receives a token reference (random string).
JWT vs Reference Token
JWT (Self-Contained Token)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Content can be decoded:
{
"sub": "1234567890",
"name": "John Doe",
"email": "john@example.com",
"roles": ["Admin", "User"]
}
Reference Token (Opaque Token)
vK9mX2pN8rT4wQ7hL3yZ6fD1sA5gJ0bC
Content is unreadable by the client. Stored on server as:
| TokenUserIdExpiresAtIsRevoked | |||
| vK9mX2pN8rT4... | user-123 | 2024-01-25 15:00 | false |
Reference Token Architecture
Flow Diagram
+----------+ +----------+ +----------+
| Client | | API | | Database |
+----+-----+ +-----+----+ +-----+----+
| | |
| 1. POST /auth/login | |
+--------------------------->| |
| | |
| | 2. Validate Credentials |
| +-------------------------->|
| | |
| | 3. Generate Random Token |
| | (32 bytes secure random) |
| | |
| | 4. Store Token + UserId |
| +-------------------------->|
| | |
| 5. Return Token | |
|<---------------------------+ |
| { "accessToken": "..." } | |
| | |
| 6. GET /api/data | |
| Header: Bearer vK9m... | |
+--------------------------->| |
| | |
| | 7. Query Token From DB |
| +-------------------------->|
| | |
| | 8. Return User + Roles |
| |<--------------------------+
| | |
| | 9. Validate & Authorize |
| | |
| 10. Return Data | |
|<---------------------------+ |
Step-by-Step Implementation
1. AccessToken Entity (Database Model)
namespace SecurityApp.API.Models;
/// <summary>
/// Reference token entity that stores token data server-side.
/// User information is accessed via navigation property (normalized approach).
/// </summary>
public class AccessToken
{
public int Id { get; set; }
public string Token { get; set; } = string.Empty;
public string UserId { get; set; } = string.Empty;
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public bool IsRevoked { get; set; }
// Navigation property for user information
public ApplicationUser User { get; set; } = null!;
}
Key Points:
- Minimal fields (only token ref and metadata)
- User information normalized (navigation property)
- IsRevoked flag for instant revocation
- ExpiresAt for token lifecycle management
2. DbContext Configuration
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
public DbSet<AccessToken> AccessTokens => Set<AccessToken>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<AccessToken>(entity =>
{
entity.HasKey(e => e.Id);
// Token must be unique - used for lookup
entity.HasIndex(e => e.Token).IsUnique();
// Foreign key relationship
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}
Index Strategy:
- Unique index on
Token- token lookup performed on every request - Foreign key cascade delete - tokens deleted when user is deleted
3. Reference Token Service
using System.Security.Cryptography;
namespace SecurityApp.API.Services;
public interface IReferenceTokenService
{
string GenerateAccessToken();
string GenerateRefreshToken();
}
public class ReferenceTokenService : IReferenceTokenService
{
public string GenerateAccessToken()
{
// 32 bytes = 256 bits of entropy (very secure)
var randomBytes = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomBytes);
// Base64 URL-safe encoding
return Convert.ToBase64String(randomBytes)
.Replace("+", "-")
.Replace("/", "_")
.Replace("=", "");
}
public string GenerateRefreshToken()
{
// Refresh tokens are longer-lived, use 64 bytes
var randomBytes = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomBytes);
return Convert.ToBase64String(randomBytes);
}
}
Security Note:
- WARNING: Use
RandomNumberGenerator, NOTRandom - 32 bytes = 256 bits entropy (brute-force impossible)
- URL-safe characters (+ and / can cause issues)
4. Custom Authentication Handler
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
namespace SecurityApp.API.Authentication;
public class ReferenceTokenAuthenticationHandler
: AuthenticationHandler<ReferenceTokenAuthenticationOptions>
{
private readonly ApplicationDbContext _dbContext;
private readonly UserManager<ApplicationUser> _userManager;
public ReferenceTokenAuthenticationHandler(
IOptionsMonitor<ReferenceTokenAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ApplicationDbContext dbContext,
UserManager<ApplicationUser> userManager)
: base(options, logger, encoder)
{
_dbContext = dbContext;
_userManager = userManager;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// 1. Extract token from Authorization header
if (!Request.Headers.ContainsKey("Authorization"))
{
return AuthenticateResult.NoResult();
}
var authorizationHeader = Request.Headers["Authorization"].ToString();
if (string.IsNullOrEmpty(authorizationHeader) ||
!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return AuthenticateResult.NoResult();
}
var token = authorizationHeader["Bearer ".Length..].Trim();
if (string.IsNullOrEmpty(token))
{
return AuthenticateResult.Fail("Invalid token");
}
// 2. Query token from database
var accessToken = await _dbContext.AccessTokens
.Include(at => at.User) // JOIN with Users table
.FirstOrDefaultAsync(at => at.Token == token);
// 3. Validate token
if (accessToken is null)
{
return AuthenticateResult.Fail("Token not found");
}
if (accessToken.IsRevoked)
{
return AuthenticateResult.Fail("Token has been revoked");
}
if (accessToken.ExpiresAt < DateTime.UtcNow)
{
return AuthenticateResult.Fail("Token has expired");
}
// 4. Load user and roles
var user = accessToken.User;
var roles = await _userManager.GetRolesAsync(user);
// 5. Create claims identity
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(ClaimTypes.Name, user.UserName!),
new Claim(ClaimTypes.Email, user.Email!)
};
if (!string.IsNullOrWhiteSpace(user.FirstName))
{
claims.Add(new Claim(ClaimTypes.GivenName, user.FirstName));
}
if (!string.IsNullOrWhiteSpace(user.LastName))
{
claims.Add(new Claim(ClaimTypes.Surname, user.LastName));
}
foreach (var role in roles)
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
}
public class ReferenceTokenAuthenticationOptions : AuthenticationSchemeOptions
{
}
Flow Breakdown:
- Extract Token: Get Bearer token from Authorization header
- Database Lookup: Find AccessToken record by token
- Validation: Check for revoked or expired status
- Load User Data: Fetch user information via navigation property
- Load Roles: Get roles via UserManager
- Create Claims: Build ClaimsIdentity
- Return Success: Return AuthenticationTicket
5. Program.cs Configuration
// Register service
builder.Services.AddScoped<IReferenceTokenService, ReferenceTokenService>();
// Register authentication handler
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = "ReferenceToken";
options.DefaultChallengeScheme = "ReferenceToken";
})
.AddScheme<ReferenceTokenAuthenticationOptions, ReferenceTokenAuthenticationHandler>(
"ReferenceToken", null);
builder.Services.AddAuthorization();
6. Auth Endpoints
Login Endpoint
private static async Task<IResult> Login(
[FromBody] LoginRequest request,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
IReferenceTokenService tokenService,
ApplicationDbContext dbContext)
{
var user = await userManager.FindByEmailAsync(request.Email);
if (user is null)
{
return Results.Unauthorized();
}
var result = await signInManager.CheckPasswordSignInAsync(
user, request.Password, lockoutOnFailure: true);
if (result.IsLockedOut)
{
return Results.BadRequest(new { Message = "Account is locked out" });
}
if (!result.Succeeded)
{
return Results.Unauthorized();
}
// Generate tokens
var accessTokenValue = tokenService.GenerateAccessToken();
var refreshTokenValue = tokenService.GenerateRefreshToken();
// Store in database
await dbContext.AccessTokens.AddAsync(new AccessToken
{
Token = accessTokenValue,
UserId = user.Id,
ExpiresAt = DateTime.UtcNow.AddMinutes(60)
});
await dbContext.RefreshTokens.AddAsync(new RefreshToken
{
Token = refreshTokenValue,
UserId = user.Id,
ExpiresAt = DateTime.UtcNow.AddDays(7)
});
await dbContext.SaveChangesAsync();
return Results.Ok(new AuthResponse
{
AccessToken = accessTokenValue,
RefreshToken = refreshTokenValue,
ExpiresIn = 3600
});
}
Refresh Token Endpoint
private static async Task<IResult> RefreshToken(
[FromBody] RefreshTokenRequest request,
IReferenceTokenService tokenService,
UserManager<ApplicationUser> userManager,
ApplicationDbContext dbContext)
{
// Validate access token
var storedAccessToken = await dbContext.AccessTokens
.FirstOrDefaultAsync(at => at.Token == request.AccessToken);
if (storedAccessToken is null)
{
return Results.BadRequest(new { Message = "Invalid access token" });
}
var userId = storedAccessToken.UserId;
// Validate refresh token
var storedRefreshToken = await dbContext.RefreshTokens
.FirstOrDefaultAsync(rt => rt.Token == request.RefreshToken && rt.UserId == userId);
if (storedRefreshToken is null ||
storedRefreshToken.IsRevoked ||
storedRefreshToken.ExpiresAt < DateTime.UtcNow)
{
return Results.BadRequest(new { Message = "Invalid or expired refresh token" });
}
var user = await userManager.FindByIdAsync(userId);
if (user is null)
{
return Results.BadRequest(new { Message = "User not found" });
}
// Generate new tokens
var newAccessTokenValue = tokenService.GenerateAccessToken();
var newRefreshTokenValue = tokenService.GenerateRefreshToken();
// Revoke old tokens
storedAccessToken.IsRevoked = true;
storedRefreshToken.IsRevoked = true;
// Store new tokens
await dbContext.AccessTokens.AddAsync(new AccessToken
{
Token = newAccessTokenValue,
UserId = user.Id,
ExpiresAt = DateTime.UtcNow.AddMinutes(60)
});
await dbContext.RefreshTokens.AddAsync(new RefreshToken
{
Token = newRefreshTokenValue,
UserId = user.Id,
ExpiresAt = DateTime.UtcNow.AddDays(7)
});
await dbContext.SaveChangesAsync();
return Results.Ok(new AuthResponse
{
AccessToken = newAccessTokenValue,
RefreshToken = newRefreshTokenValue,
ExpiresIn = 3600
});
}
Revoke Token Endpoint
private static async Task<IResult> RevokeAccessToken(
[FromBody] RevokeAccessTokenRequest request,
ApplicationDbContext dbContext)
{
var accessToken = await dbContext.AccessTokens
.FirstOrDefaultAsync(at => at.Token == request.AccessToken);
if (accessToken is null)
{
return Results.NotFound(new { Message = "Access token not found" });
}
// Instant revocation
accessToken.IsRevoked = true;
await dbContext.SaveChangesAsync();
return Results.Ok(new { Message = "Access token revoked successfully" });
}
Security Best Practices
1. Token Rotation
// Both access and refresh tokens are renewed on each refresh
storedAccessToken.IsRevoked = true;
storedRefreshToken.IsRevoked = true;
// New tokens are created
var newAccessToken = tokenService.GenerateAccessToken();
var newRefreshToken = tokenService.GenerateRefreshToken();
Why?
- If refresh token is stolen, it becomes invalid after one use
- Token reuse detection (suspicious if same refresh token used twice)
2. Token Cleanup Job
public class TokenCleanupService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<TokenCleanupService> _logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
// Delete expired tokens older than 7 days
var cutoffDate = DateTime.UtcNow.AddDays(-7);
var expiredTokens = await dbContext.AccessTokens
.Where(at => at.ExpiresAt < cutoffDate)
.ToListAsync(stoppingToken);
dbContext.AccessTokens.RemoveRange(expiredTokens);
await dbContext.SaveChangesAsync(stoppingToken);
_logger.LogInformation("Cleaned up {Count} expired tokens", expiredTokens.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error cleaning up tokens");
}
// Run every 6 hours
await Task.Delay(TimeSpan.FromHours(6), stoppingToken);
}
}
}
Register in Program.cs:
builder.Services.AddHostedService<TokenCleanupService>();
3. Rate Limiting
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("auth", opt =>
{
opt.Window = TimeSpan.FromMinutes(1);
opt.PermitLimit = 5; // Max 5 login attempts per minute
opt.QueueLimit = 0;
});
});
// Apply to auth endpoints
group.MapPost("/login", Login)
.RequireRateLimiting("auth");
4. IP Address Tracking
public class AccessToken
{
// ... existing properties
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
}
// Store during login
await dbContext.AccessTokens.AddAsync(new AccessToken
{
Token = accessTokenValue,
UserId = user.Id,
IpAddress = httpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = httpContext.Request.Headers["User-Agent"],
ExpiresAt = DateTime.UtcNow.AddMinutes(60)
});
Performance Optimizations
1. Caching Strategy
public class CachedReferenceTokenAuthenticationHandler
: AuthenticationHandler<ReferenceTokenAuthenticationOptions>
{
private readonly IDistributedCache _cache;
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var token = ExtractToken();
// Try cache first
var cachedUser = await _cache.GetStringAsync($"token:{token}");
if (!string.IsNullOrEmpty(cachedUser))
{
var user = JsonSerializer.Deserialize<UserDto>(cachedUser);
return CreateAuthResult(user);
}
// Fallback to database
var accessToken = await _dbContext.AccessTokens
.Include(at => at.User)
.FirstOrDefaultAsync(at => at.Token == token);
// Cache for 5 minutes
await _cache.SetStringAsync(
$"token:{token}",
JsonSerializer.Serialize(accessToken.User),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
});
return CreateAuthResult(accessToken.User);
}
}
Redis Configuration:
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "SecurityApp:";
});
2. Database Indexing
-- Token lookup index (already unique)
CREATE UNIQUE INDEX IX_AccessTokens_Token ON AccessTokens(Token);
-- UserId lookup for user's active sessions
CREATE INDEX IX_AccessTokens_UserId_ExpiresAt
ON AccessTokens(UserId, ExpiresAt)
WHERE IsRevoked = 0;
-- Cleanup job index
CREATE INDEX IX_AccessTokens_ExpiresAt
ON AccessTokens(ExpiresAt)
WHERE IsRevoked = 0;
3. Query Optimization
// BAD: N+1 query problem
var tokens = await _dbContext.AccessTokens.ToListAsync();
foreach (var token in tokens)
{
var user = await _userManager.FindByIdAsync(token.UserId);
var roles = await _userManager.GetRolesAsync(user);
}
// GOOD: Single query with includes
var tokens = await _dbContext.AccessTokens
.Include(at => at.User)
.ThenInclude(u => u.UserRoles)
.ThenInclude(ur => ur.Role)
.ToListAsync();
4. Connection Pooling
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(5),
errorNumbersToAdd: null);
// Connection pooling (default enabled)
sqlOptions.CommandTimeout(30);
});
});
Comparison Table
| FeatureJWTReference Token | ||
| Token Size | Large (contains claims) | Small (just random string) |
| Validation | Signature verification | Database lookup |
| Revocation | Impossible (valid until expiry) | Instant (IsRevoked flag) |
| User Data Update | Must renew token | Automatically current (via JOIN) |
| Network Overhead | High (large token per request) | Low (small token) |
| Database Load | None | Query per request |
| Stateless | Yes | No (stateful) |
| Scalability | Easy | Requires caching |
| Security | Token content visible | Token content opaque |
| Logout | Client-side deletion | Server-side revoke |
Conclusion
Reference Token implementation should be preferred in these scenarios:
Use When:
- Instant token revocation is required
- User information changes frequently
- Sensitive information must not be sent to client
- Session management is needed
- Audit logging is important
Don't Use When:
- High traffic scalability is critical (without caching)
- Stateless architecture is required
- Microservices inter-service auth is needed
- Database load must be minimal
Hybrid Approach
Best option: JWT + Reference Token
- JWT for public APIs (stateless, scalable)
- Reference Token for web/mobile apps (secure, revocable)