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.
| Token | ClockSkew | Result |
|---|---|---|
nbf = receiver's now + 33 s | 0 | Rejected: IDX10222 (not yet valid) |
nbf = receiver's now + 33 s | 5 min (default) | Accepted |
Same issuer, nbf backdated by 60 s | 0 | Accepted |
exp = 10 s in the past | 0 | Rejected: IDX10223 (expired) |
exp = 10 s in the past | 5 min (default) | Accepted |
exp = 400 s in the past | 5 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
nbfthat is ahead of the receiver's clock fails withIDX10222whenClockSkewis zero. - The default skew is 5 minutes and covers both ends of the token's lifetime.
- Backdate
nbfon the issuer, keep a small explicit skew on the receiver, and logOnAuthenticationFailed.
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.