Skip to content
ecdsa.com

Verify an ES256 JWT · .NET

How to verify an ES256 JWT in C# (.NET)

To verify an ES256 JWT in C#, use JsonWebTokenHandler from Microsoft.IdentityModel.JsonWebTokens — the library behind ASP.NET Core's JwtBearer authentication — with TokenValidationParameters that pin ES256 and name the issuer. Because a JWS signature is raw r‖s and that is .NET's default ECDSA format, the bare signature check needs no conversion at all, as the second snippet shows.

Tested with Microsoft.IdentityModel.JsonWebTokens 8.23.0 (.NET 10.0.13 (SDK 10.0.402), Linux) and .NET 10.0.13 (System.Buffers.Text.Base64Url, BCL-only variant).

verify.cs — ES256 pinned, issuer and lifetime checked
#:package Microsoft.IdentityModel.JsonWebTokens@8.23.0
using System.Security.Cryptography;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
var publicKey = ECDsa.Create();
publicKey.ImportFromPem(File.ReadAllText("pub.pem"));
var parameters = new TokenValidationParameters
{
IssuerSigningKey = new ECDsaSecurityKey(publicKey),
ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256], // "ES256" and nothing else
ValidIssuer = "https://ecdsa.com",
ValidateAudience = false, // this token has no aud; set ValidAudience when yours do
ClockSkew = TimeSpan.FromSeconds(30), // the default allows five minutes
};
TokenValidationResult result = await new JsonWebTokenHandler()
.ValidateTokenAsync(File.ReadAllText("token.txt").Trim(), parameters);
if (!result.IsValid) throw result.Exception; // never read claims from an invalid result
var jwt = (JsonWebToken)result.SecurityToken;
Console.WriteLine($"valid ES256 token for {jwt.Subject}, expires {jwt.ValidTo:O}");
the signature check alone, with nothing but the base class library
using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
static class Jws
{
/// <summary>The ES256 signature check alone: JWS signatures are raw r‖s, .NET's default format.</summary>
public static bool Es256SignatureValid(string token, ECDsa publicKey)
{
string[] parts = token.Split('.');
if (parts.Length != 3) throw new FormatException("not a compact JWS");
byte[] signingInput = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}"); // the base64url text
byte[] signature = Base64Url.DecodeFromChars(parts[2]); // .NET 9+: base64url without padding
return publicKey.VerifyData(signingInput, signature, HashAlgorithmName.SHA256);
}
}

How it works

  1. #:package pins the NuGet package for a file-based app (dotnet run verify.cs, .NET 10). In a project, add the same package with dotnet add package Microsoft.IdentityModel.JsonWebTokens --version 8.23.0.
  2. ECDsaSecurityKey wraps an ECDsa loaded from the SPKI PEM; ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256] refuses every header alg except ES256.
  3. ValidateTokenAsync checks signature, issuer and lifetime and returns a result object instead of throwing. Read claims only after IsValid is true.
  4. The BCL-only variant shows the mechanism: the signed bytes are the ASCII text header.payload, Base64Url.DecodeFromChars (.NET 9+) decodes the unpadded segment, and VerifyData with its default format reads the 64-byte r‖s directly.

Gotchas

  • Audience validation is on by default. A token without aud — or a configuration without ValidAudience — fails with IDX10208 / SecurityTokenInvalidAudienceException (the run shows it). Turn it off only when your tokens genuinely carry no audience.
  • A forged token and a token with a disallowed alg both fail with IDX10517: Signature validation failed. The token's kid is missing when the token has no kid. It does not mean you must add a kid; it means no key validated the signature. The detailed reason is hidden as PII until IdentityModelEventSource.ShowPII = true (development only).
  • ClockSkew defaults to five minutes, so a token "expires" up to five minutes late. Set it explicitly.
  • JsonWebTokenHandler.ReadJsonWebToken and the older JwtSecurityTokenHandler.ReadJwtToken only decode — they check neither signature nor expiry. Do not authorize anything with their output.
  • Convert.FromBase64String rejects JWT segments (-, _ and missing padding). Use Base64Url from System.Buffers.Text, or Base64UrlEncoder from the IdentityModel package.

Related recipes