← Back to Blog

JWT Clock Skew in .NET: Fixing IDX10222 (Token Not Yet Valid)

Why a valid JWT is rejected as not yet valid when clocks differ, how ClockSkew works (default 5 minutes), and how to backdate nbf. Tested on .NET 8.

Pankaj Kumar
Senior Software Engineer — .NET, Blazor, ASP.NET Core
Articles are drafted with AI assistance and reviewed by the author. Code samples and results are run before publication, and each article says how its results were produced.
Published 30 Jun 2026· Last reviewed 14 Oct 2026· 6 min read · About the author →
All code and output in this article were run on .NET 8.0 with Microsoft.IdentityModel 8.0.2 on 14 Oct 2026.
Key takeaways
  • A JWT whose nbf (not before) is even a second ahead of the receiver's clock is rejected when ClockSkew is zero
  • The default ClockSkew in TokenValidationParameters is 5 minutes, and it applies to both nbf and exp
  • Because the skew also extends exp, a token stays acceptable for up to ClockSkew after it expires
  • Issuer-side fix: backdate nbf by 30-60 seconds. Receiver-side fix: keep a small, explicit ClockSkew instead of TimeSpan.Zero
  • Log the exception from OnAuthenticationFailed: its message names the exact check that failed
Table of Contents

What the error looks like

When a JWT is not yet valid from the receiver's point of view, Microsoft.IdentityModel (the library behind ASP.NET Core's JWT bearer authentication) raises a SecurityTokenNotYetValidException with this message. This is the real output from the test described below:

IDX10222: Lifetime validation failed. The token is not yet valid.
ValidFrom (UTC): '14-10-2026 10.52.28 AM', Current time (UTC): '14-10-2026 10.51.55 AM'.

The token is not malformed and the signature is fine. The issuer simply stamped a nbf (not before) time that the receiver's clock has not reached yet. In that run the gap was 33 seconds.

Why it happens

Three registered claims describe a token's lifetime: iat (issued at), nbf (not valid before) and exp (expires). The receiver compares them with its own clock. If the issuer and the receiver are different machines, their clocks are never exactly equal. Whenever the issuer's clock is ahead, a token created "now" has an nbf that is still in the receiver's future. The result is an intermittent 401 that depends on which two clocks happen to be involved, which is why it can pass in a single-machine test and fail between two servers.

What ClockSkew changes (tested)

Tokens were created with JsonWebTokenHandler and validated with different ClockSkew values. The default for TokenValidationParameters.ClockSkew printed as 00:05:00, which is 5 minutes.

TokenClockSkewResult
nbf = receiver's now + 33 s0Rejected: IDX10222 (not yet valid)
nbf = receiver's now + 33 s5 min (default)Accepted
Same issuer, nbf backdated by 60 s0Accepted
exp = 10 s in the past0Rejected: IDX10223 (expired)
exp = 10 s in the past5 min (default)Accepted
exp = 400 s in the past5 min (default)Rejected: IDX10223 (expired)

Two things follow from the table. First, a receiver that sets ClockSkew = TimeSpan.Zero has no tolerance at all, so any positive clock difference in the wrong direction is a failure. Second, the skew is symmetric: it forgives a slightly early nbf and keeps an expired token alive for the same amount of time.

Fix 1: backdate nbf when you issue the token

If you control the issuer but not the receiver, set NotBefore a little in the past. A token that already "became valid" 60 seconds ago is accepted by receivers whose clocks run up to a minute behind yours, even with zero skew.

public string CreateToken()
{
    var now = DateTime.UtcNow;
    var descriptor = new SecurityTokenDescriptor
    {
        Issuer   = "https://issuer.example",
        Audience = "api",
        Subject  = new ClaimsIdentity(new[] { new Claim("sub", "client-123") }),
        IssuedAt  = now,
        NotBefore = now.AddSeconds(-60),   // absorbs a receiver clock that runs up to a minute behind
        Expires   = now.AddMinutes(10),
        SigningCredentials = _credentials
    };
    return new JsonWebTokenHandler().CreateToken(descriptor);
}

This only moves nbf. exp is untouched, so the token's real lifetime is still ten minutes from creation.

Fix 2: keep an explicit, small ClockSkew on the receiver

If you control the receiver, leave the default or set a deliberate value. Zero feels strict, but it only moves the failure to whichever side has the slower clock.

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer   = true,
            ValidIssuer      = "https://issuer.example",
            ValidateAudience = true,
            ValidAudience    = "api",
            ValidateLifetime = true,
            ClockSkew        = TimeSpan.FromMinutes(2)  // default is 5 minutes
        };
    });

The trade-off is real: with a 2-minute skew, a token stays acceptable for up to 2 minutes after its exp. For tokens that live only a few minutes, keep the skew small. If you need a hard cut-off, clock-based validation alone is not enough and you need server-side revocation or one-time-use tokens.

Log the real reason for a 401

A failed JWT check looks like a plain 401 from the client's side. To see which check failed, log the exception from the OnAuthenticationFailed event. Its type and message name the problem (IDX10222 not yet valid, IDX10223 expired, an invalid signature, a wrong audience, and so on).

options.Events = new JwtBearerEvents
{
    OnAuthenticationFailed = context =>
    {
        var logger = context.HttpContext.RequestServices
            .GetRequiredService<ILoggerFactory>().CreateLogger("JwtBearer");
        logger.LogWarning("JWT rejected: {Type}: {Message}",
            context.Exception.GetType().Name, context.Exception.Message);
        return Task.CompletedTask;
    }
};

Check the clocks themselves

If you suspect drift, compare each machine against a reference time source. On Windows, w32tm /query /status shows the sync source and w32tm /stripchart /computer:time.windows.com /samples:5 prints the measured offset. On Linux, chronyc tracking (or timedatectl) reports the system clock's offset. Self-managed servers whose time service is stopped or misconfigured are the usual place for large offsets.

To compare a failing token's times against the real UTC time, paste it into the JWT Decoder and read nbf and exp directly. If the token looks correct but keeps being rejected, check the clocks before you touch the signing keys.

Summary

  • An nbf that is ahead of the receiver's clock fails with IDX10222 when ClockSkew is zero.
  • The default skew is 5 minutes and covers both ends of the token's lifetime.
  • Backdate nbf on the issuer, keep a small explicit skew on the receiver, and log OnAuthenticationFailed.

Update, 14 Oct 2026: an earlier version of this article told the topic as a first-person incident story. That story was not a real event, so it has been removed and the article rewritten around results that can be reproduced. Every result above was re-run.

Try the free tool

JWT Decoder

Decode and inspect JWT tokens instantly: view the header, payload claims, expiry time, and signing algorithm. Useful for comparing nbf and exp against the real UTC time.

Open JWT Decoder