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

  1. Introduction
  2. JWT vs Reference Token
  3. Reference Token Architecture
  4. Step-by-Step Implementation
  5. Security Best Practices
  6. Performance Optimizations
  7. Testing
  8. Monitoring and Logging
  9. Comparison Table
  10. Conclusion

Introduction

Modern web applications typically use JWT (JSON Web Token) for authentication. However, JWT has several disadvantages:

  1. Cannot instantly revoke tokens (token remains valid until expiration)
  2. Token content is exposed to the client (readable via base64 decode)
  3. Large token size creates network overhead
  4. 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-1232024-01-25 15:00false

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:

  1. Minimal fields (only token ref and metadata)
  2. User information normalized (navigation property)
  3. IsRevoked flag for instant revocation
  4. 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:

  1. Unique index on Token - token lookup performed on every request
  2. 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:

  1. WARNING: Use RandomNumberGenerator, NOT Random
  2. 32 bytes = 256 bits entropy (brute-force impossible)
  3. 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:

  1. Extract Token: Get Bearer token from Authorization header
  2. Database Lookup: Find AccessToken record by token
  3. Validation: Check for revoked or expired status
  4. Load User Data: Fetch user information via navigation property
  5. Load Roles: Get roles via UserManager
  6. Create Claims: Build ClaimsIdentity
  7. 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?

  1. If refresh token is stolen, it becomes invalid after one use
  2. 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 SizeLarge (contains claims)Small (just random string)
ValidationSignature verificationDatabase lookup
RevocationImpossible (valid until expiry)Instant (IsRevoked flag)
User Data UpdateMust renew tokenAutomatically current (via JOIN)
Network OverheadHigh (large token per request)Low (small token)
Database LoadNoneQuery per request
StatelessYesNo (stateful)
ScalabilityEasyRequires caching
SecurityToken content visibleToken content opaque
LogoutClient-side deletionServer-side revoke

Conclusion

Reference Token implementation should be preferred in these scenarios:

Use When:

  1. Instant token revocation is required
  2. User information changes frequently
  3. Sensitive information must not be sent to client
  4. Session management is needed
  5. Audit logging is important

Don't Use When:

  1. High traffic scalability is critical (without caching)
  2. Stateless architecture is required
  3. Microservices inter-service auth is needed
  4. Database load must be minimal

Hybrid Approach

Best option: JWT + Reference Token

  1. JWT for public APIs (stateless, scalable)
  2. Reference Token for web/mobile apps (secure, revocable)
Share this lesson: