Detecting Stolen Tokens

Introduction to Token Security

Welcome back! In the previous lesson, we established the foundation of our authentication system by implementing and rotating refresh tokens in a C# ASP.NET Core application. While rotation is a powerful security feature, it is not a silver bullet. If an attacker steals a valid refresh token, they can potentially maintain access until that token is rotated or expires.

In this lesson, we will focus on detection. We will build an intelligent system that monitors how tokens are used, identifies suspicious patterns—such as a token being used from two different countries simultaneously—and automatically revokes access when theft is suspected.

Token Security Fundamentals

Token security encompasses the strategies and code logic used to protect authentication artifacts from unauthorized use. In modern stateless architectures, tokens are effectively the "keys to the kingdom." Unlike session-based authentication where the server holds the state, a token is self-contained. If an attacker possesses it, they can impersonate the user without needing the user's password.

Because refresh tokens are long-lived and powerful (they can generate new access tokens repeatedly), they are high-value targets for attackers. Therefore, we must implement "defense in depth"—layering rotation, secure storage, and active monitoring to protect user accounts.

Understanding Token Revocation Policies

Before we dive into detection, it is crucial to understand the different approaches to token revocation:

Single-Use Rotation: Each refresh token can only be used once. After use, that specific token is invalidated and a new one is issued. Other tokens from different sessions remain valid. This allows multiple concurrent sessions (e.g., mobile + desktop) while protecting against token replay attacks.

Single-Session Policy: When a refresh token is used, ALL other refresh tokens for that user are immediately revoked. This enforces one active session at a time, providing maximum security but poor user experience—logging in on your phone would forcibly log out your laptop.

Conditional Revocation (Our Approach): This lesson implements a hybrid approach. During normal operation, tokens are rotated individually (single-use pattern). However, when suspicious activity is detected—such as the same token family being used from multiple IP addresses—the system automatically switches to aggressive mode and revokes ALL tokens for that user. This balances security with usability: users can have multiple sessions under normal circumstances, but the system protects them by killing all sessions when compromise is suspected.

Why Security Matters

The stakes are higher with refresh tokens than with short-lived access tokens. If an access token is stolen, it is annoying but temporary; it will expire in minutes. However, a stolen refresh token allows for persistent access. It can persist across sessions, devices, and sometimes even password changes if the revocation logic is not watertight.

We must address several specific risks:

  • Long Lifespans: The window of opportunity for an attacker is much wider.
  • Extended Access: They grant access without requiring user interaction.
  • Scalability vs. Control: While we want the system to be stateless for performance, we need just enough state (logging) to detect anomalies.

Authentication Trade-offs

When building this system, it is helpful to understand the architectural trade-offs we are making. Token-based authentication offers significant advantages but introduces specific complexities we must manage.

Pros:

  • Stateless Scalability: The server does not need to hold a session for every active user, reducing memory overhead.
  • Performance: Fewer database lookups are required for standard API requests (using Access Tokens).
  • Cross-Domain Support: Tokens work well across different domains and microservices.

Cons:

  • Revocation Complexity: It is harder to "kill" a session immediately compared to server-side sessions.
  • Theft Vulnerability: Stolen tokens are valid until expiry or revocation.
  • Implementation Effort: Safe token management requires robust rotation and detection logic.

Tracking Token Usage

To detect theft, we must first observe normal behavior. We cannot detect an anomaly if we do not have a baseline. We will implement a RefreshLog model, which acts as a security journal. Every time a refresh token is presented to our API, we record the context of that request.

Here is the data model we will use to create this audit trail:

namespace PasteBinBackend.Models;

/// <summary>
/// Logs each refresh token usage for security analysis 
/// and token theft detection.
/// </summary>
public sealed class RefreshLog
{
    public long Id { get; set; }

    public string Token { get; set; } = string.Empty;

    // Nullable to allow tracking invalid token attempts
    // where we may not know the user
    public int? UserId { get; set; }
    public User? User { get; set; }

    public DateTime UsedAtUtc { get; set; } = DateTime.UtcNow;

    public string IpAddress { get; set; } = string.Empty;

    public string UserAgent { get; set; } = string.Empty;

    public bool Successful { get; set; } = false;
}

This model captures the "Who" (UserId), "What" (Token), "When" (UsedAtUtc), and crucially, the "Where" and "How" (IpAddress and UserAgent). The Successful flag lets us distinguish between valid refresh attempts and failed ones — a high rate of failed attempts for a user is itself a suspicious signal.

Next, we register this model in our AppDbContext so Entity Framework Core can manage the table.

using Microsoft.EntityFrameworkCore;
using PasteBinBackend.Models;

namespace PasteBinBackend.Data;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options) { }

    public DbSet<User> Users => Set<User>();
    public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
    public DbSet<RefreshLog> RefreshLogs => Set<RefreshLog>();
}

In a production application, you would typically add database indices on columns you query frequently. For RefreshLog, adding indices on UserId and Token would be beneficial because our detection logic queries logs by UserId on every refresh request, and lookups by Token are needed to correlate specific tokens with their usage history. Without these indices, the database would perform full table scans on every refresh attempt, which degrades performance as the log table grows. You can add these indices in the OnModelCreating method of your DbContext if needed.

Helper Functions

With the data layer ready, we need utility functions that our endpoints will use. In Minimal API applications, we define these as static helper functions in Program.cs rather than in a controller class. This keeps the code straightforward and consistent with how ASP.NET Core Minimal APIs are structured.

First, we need a helper to reliably extract the client's IP address. If your API sits behind a load balancer (like Nginx or AWS ALB), the direct connection IP will be that of the load balancer, not the user. The X-Forwarded-For header contains the original client IP in these cases.

static string GetClientIp(HttpContext ctx)
{
    var forwarded = ctx.Request.Headers["X-Forwarded-For"].ToString();
    if (!string.IsNullOrWhiteSpace(forwarded))
    {
        return forwarded.Split(',')[0].Trim();
    }

    return ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
}

Analyzing Usage Patterns

Before we implement the refresh endpoint, we need a helper function to analyze the logs. This function queries the history of a specific user to determine their "Risk Level."

The logic here is heuristic-based. We look for:

  1. IP Velocity: Is the user accessing the system from many different IP addresses in a short time?
  2. Device Consistency: Are they switching User Agents frequently?
  3. Failure Rates: Are there many failed attempts?

We define this as a static helper function that our endpoints can call:

static async Task<object> AnalyzeRefreshLogsAsync(
    int userId, AppDbContext db, CancellationToken ct)
{
    try
    {
        var oneWeekAgo = DateTime.UtcNow.AddDays(-7);

        // Fetch recent activity
        var logs = await db.RefreshLogs
            .Where(log => log.UserId == userId && log.UsedAtUtc >= oneWeekAgo)
            .OrderByDescending(log => log.UsedAtUtc)
            .ToListAsync(ct);

        if (logs.Count == 0)
        {
            return new { userId, message = "No recent refresh activity" };
        }

        // Extract distinct usage points
        var uniqueIps = logs
            .Where(log => !string.IsNullOrEmpty(log.IpAddress))
            .Select(log => log.IpAddress)
            .Distinct()
            .ToHashSet();

        var failedAttempts = logs.Count(log => !log.Successful);

        // Heuristic Risk Assessment
        string riskLevel = "low";

        if (uniqueIps.Count > 2) riskLevel = "high";
        else if (uniqueIps.Count > 1 || failedAttempts > 3) riskLevel = "medium";

        return new
        {
            userId,
            period = "Last 7 days",
            refreshAttempts = logs.Count,
            uniqueIpAddresses = uniqueIps.ToList(),
            riskLevel,
            message = riskLevel == "high"
                ? "Suspicious activity detected"
                : "Normal usage pattern"
        };
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Error analyzing refresh logs: {ex.Message}");
        return new { error = "Analysis failed" };
    }
}

Detecting Theft During Refresh

This is the core of our defense. When a client requests a token refresh, we do not simply check if the token is valid. We also check the context of the request against recent history.

Important Note on Production Deployments: The detection logic implemented here uses a simplified heuristic suitable for learning the concepts. It examines the last 5 successful refresh attempts regardless of time window. This approach has a high false-positive rate in real-world scenarios—a user who legitimately switches between home, office, and mobile networks over several days would trigger theft detection unnecessarily.

For production systems, you should implement more sophisticated detection strategies:

Time-Based Velocity Checks: Instead of looking at the last N logs, examine activity within a narrow time window (e.g., 15 minutes). A token being used from London and then Tokyo within minutes is suspicious; the same pattern over days is normal travel.

Token Reuse Detection: Track which specific tokens have already been used and rotated. If a previously-rotated token (one that should be "dead") is presented again, this is a strong theft indicator. Someone is replaying an old token, which is clear evidence of compromise. This is far more reliable than IP analysis alone.

Behavioral Baselines: Build per-user profiles over time. If a user normally accesses from 2-3 IPs (home, work, phone carrier), seeing a 4th from a different country is anomalous. If they regularly travel internationally, it is expected.

With these caveats in mind, let's examine our educational implementation that demonstrates the detection pattern:

The logic flow is as follows:

  1. Capture Context: Get the IP and User-Agent from the incoming HTTP request.
  2. Log Immediately: Create an audit record before validation. Even failed attempts provide valuable security data.
  3. Validate: Check if the token exists and is not expired.
  4. Detect Anomalies: Query recent logs. If the same token family is being used from multiple IPs concurrently, this is a strong indicator of theft (Cookie hijacking or token exfiltration).
  5. Act: If theft is suspected, revoke all tokens for that user immediately to sever the attacker's access. Note that this is different from normal operation—under normal circumstances, only the used token would be rotated. This aggressive revocation only occurs when suspicious patterns are detected.

Using Minimal APIs, we define this endpoint directly in Program.cs:

app.MapPost("/api/auth/refresh", async (
    RefreshRequest request,
    HttpContext http,
    AppDbContext db,
    CancellationToken ct) =>
{
    if (string.IsNullOrWhiteSpace(request.RefreshToken))
    {
        return Results.BadRequest(new { error = "Missing refresh token" });
    }

    // 1. Capture Context
    var ip = GetClientIp(http);
    var userAgent = http.Request.Headers.UserAgent.ToString();

    var stored = await db.RefreshTokens
        .FirstOrDefaultAsync(rt => rt.Token == request.RefreshToken, ct);

    // 2. Log Immediately (even for invalid tokens)
    db.RefreshLogs.Add(new RefreshLog
    {
        UserId = stored?.UserId,
        UsedAtUtc = DateTime.UtcNow,
        IpAddress = ip,
        UserAgent = userAgent,
        Successful = stored != null && stored.ExpiresAtUtc > DateTime.UtcNow
    });
    await db.SaveChangesAsync(ct);

    // 3. Validate
    if (stored is null || stored.ExpiresAtUtc <= DateTime.UtcNow)
    {
        return Results.Unauthorized();
    }

    // 4. Detect Anomalies (Simplified Theft Check)
    // PRODUCTION NOTE: This checks last 5 attempts regardless of time,
    // causing false positives for legitimate network changes over days.
    // Better approach: Check for rapid IP changes (< 15min window) or
    // detect reuse of already-rotated tokens (stronger theft signal).
    var recentLogs = await db.RefreshLogs
        .Where(l => l.UserId == stored.UserId && l.Successful)
        .OrderByDescending(l => l.UsedAtUtc)
        .Take(5)
        .ToListAsync(ct);

    var uniqueIps = recentLogs
        .Select(l => l.IpAddress)
        .Distinct()
        .ToList();

    // If we see > 1 IP address usage recently, flag it.
    if (recentLogs.Count >= 2 && uniqueIps.Count > 1)
    {
        Console.WriteLine(
            $"Theft Detected: User {stored.UserId} IPs: {string.Join(", ", uniqueIps)}");

        // 5. Act: Revoke Everything (ONLY when theft is suspected)
        var allTokens = db.RefreshTokens
            .Where(rt => rt.UserId == stored.UserId);
        db.RefreshTokens.RemoveRange(allTokens);
        await db.SaveChangesAsync(ct);

        return Results.Json(
            new
            {
                error = "Security alert: Unusual access pattern detected",
                requiresReauthentication = true
            },
            statusCode: StatusCodes.Status401Unauthorized);
    }

    // If safe, proceed with normal rotation
    var userId = stored.UserId;
    db.RefreshTokens.Remove(stored);
    await db.SaveChangesAsync(ct);

    var auth = await CreateAuthTokensAsync(userId, db, ct);
    return Results.Json(auth);
});

record RefreshRequest(string? RefreshToken);

Administrative Insights

Finally, we expose the analysis logic via an endpoint. This allows customer support or security teams to manually investigate a user account if a customer complains about being logged out or suspects a hack.

app.MapGet("/api/admin/analyze-logs/{userId}", async (
    int userId, AppDbContext db, CancellationToken ct) =>
{
    if (userId <= 0)
    {
        return Results.BadRequest(new { error = "Invalid user ID" });
    }

    var analysis = await AnalyzeRefreshLogsAsync(userId, db, ct);
    return Results.Json(analysis);
});

Verifying Detection

To ensure our security measures actually work, we need to simulate an attack. We will write a simple test using HttpClient. This test replicates a "Token Replay" attack where a valid token is stolen and used from a different IP address.

The test performs the following steps:

  1. Normal Usage: A user refreshes their token from "Home" (IP A).
  2. Attack: An attacker tries to use that same token from "Unknown Location" (IP B).
  3. Verification: The system should reject the attacker and revoke the user's access to prevent further damage.

We simulate different IP addresses by setting the X-Forwarded-For header on each request:

using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;

var client = new HttpClient();

async Task<HttpResponseMessage> SendRefreshRequest(
    string token, string ip)
{
    var request = new HttpRequestMessage(
        HttpMethod.Post, "http://localhost:3001/api/auth/refresh")
    {
        Content = JsonContent.Create(new { refreshToken = token })
    };
    request.Headers.Add("X-Forwarded-For", ip);
    return await client.SendAsync(request);
}

var refreshToken = "simulated-valid-token"; // Assume seeded in DB

// Step 1: Legitimate user refreshes from home
Console.WriteLine("Step 1: Legitimate user refresh...");
var response1 = await SendRefreshRequest(refreshToken, "192.168.1.10");
Console.WriteLine($"Response: {(int)response1.StatusCode}");

// Step 2: Attacker uses stolen token from different IP
Console.WriteLine("Step 2: Attacker attempts refresh from new IP...");
var response2 = await SendRefreshRequest(refreshToken, "203.0.113.42");
Console.WriteLine($"Response: {(int)response2.StatusCode}");

// Step 3: Verify system reaction
var content = await response2.Content.ReadFromJsonAsync<JsonElement>();

if (response2.StatusCode == HttpStatusCode.Unauthorized &&
    content.TryGetProperty("requiresReauthentication", out _))
{
    Console.WriteLine("SUCCESS: Theft detected. Tokens revoked.");
}
else
{
    Console.WriteLine("FAILURE: Attack was allowed.");
}

Sample Test Output

When you run this test, the output demonstrates the system recognizing the anomaly. The change in IP address for the same token family triggers the detection logic in our refresh endpoint.

Step 1: Legitimate user refresh...
Response: 200

Step 2: Attacker attempts refresh from new IP...
Response: 401

SUCCESS: Theft detected. Tokens revoked.

This output confirms that the API successfully identified that the second request, coming from a different IP address than the recent history suggested, was suspicious. Consequently, it blocked the request and triggered a full revocation.

Best Practices

While the implementation above significantly hardens your API, security is an evolving landscape. Keep these best practices in mind:

  • Log Retention: Don't keep logs forever. Implement a cleanup job to delete RefreshLogs older than 30 or 60 days to manage database size and respect user privacy.
  • Geo-Location: In a production app, resolve IP addresses to Countries or Cities. Blocking a token moving from "London" to "New York" in 5 minutes is more accurate than just checking raw IP strings.
  • User Notification: When theft is detected, send an email to the user: "We detected suspicious activity and logged you out. Please change your password."

Summary and Conclusion

In this lesson, we moved beyond simple token rotation and implemented an active defense system. We created a detailed audit trail using Entity Framework Core, designed heuristic logic to spot anomalies such as IP hopping, and built an automated response mechanism that prioritizes security by revoking compromised sessions.

Critically, our implementation uses a conditional revocation policy: during normal operation, users can maintain multiple concurrent sessions across devices. However, when suspicious patterns emerge—such as rapid IP changes—the system automatically escalates its response and revokes all sessions to protect the user. This balances security with usability better than a strict single-session approach.

You now have a robust system that not only manages the lifecycle of tokens but actively protects them from abuse. These patterns form the backbone of modern, secure authentication systems in ASP.NET Core. In the next stage of the course, we will apply these concepts in a hands-on lab environment.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal