Changelog
Notable changes in each release.
Provides a clean, minimal API for creating, validating and reading JSON Web Tokens (JWT) in .NET.
dotnet add package ArturRios.Jwt
JwtConfiguration, with HMAC-SHA256 (a shared secret) or ES256 (an EC P-256 private key)id claim, and the kid naming the key that signed itJwtConfiguration with FluentValidation rules before using itusing ArturRios.Jwt;
var configuration = new JwtConfiguration(
ExpirationInSeconds: 3600,
Issuer: "my-api",
Audience: "my-app",
Secret: "a-secret-key-that-is-at-least-32-bytes-long",
Claims: new Dictionary<string, string> { { "id", "42" } }
);
var validator = new JwtConfigurationValidator();
var result = validator.Validate(configuration);
if (!result.IsValid)
{
// inspect result.Errors
}
var handler = new JwtHandler();
var token = handler.CreateToken(configuration);
var isValid = await handler.IsTokenValidAsync(token, configuration.Secret);
Replacing a secret outright is a cutover: every token in flight becomes invalid the moment the new
one takes effect. Supplying Keys instead makes it a rotation — one key signs, several are accepted.
var configuration = new JwtConfiguration(
ExpirationInSeconds: 3600,
Issuer: "my-api",
Audience: "my-app",
Secret: string.Empty,
Claims: new Dictionary<string, string> { { "id", "42" } }
)
{
Keys = [new JwtKey("2026-08", previousSecret), new JwtKey("2026-09", currentSecret)],
SigningKeyId = "2026-09"
};
var isValid = await handler.IsTokenValidAsync(token, configuration.Keys);
Tokens are stamped with the signing key’s id in their kid header, and IsTokenValidAsync checks a
token against the key it names. Rotate in three steps:
Keys on every instance, leaving SigningKeyId alone. Nothing changes yet —
the new key is merely accepted.SigningKeyId at the new key. New tokens are signed with it; the ones already issued still
validate against the old one.ExpirationInSeconds later — drop
it from Keys.Step 3 is what completes the rotation, and doing it earlier is exactly what you want when a key has leaked rather than merely aged: withdrawing a key invalidates its tokens immediately.
A token whose kid names a key that is not in Keys is refused without trying the others, and a
token carrying no kid at all — one issued before you adopted rotation — is tried against every key,
so turning this on does not sign anyone out. The kid selects a key and grants nothing: the
signature still decides.
Note:
SecretandKeysare alternatives. WithKeysset andSigningKeyIdnaming one of them,Secretis unused; without them, everything behaves exactly as it did before this existed. WithKeysset but noSigningKeyId, tokens are still signed withSecretand carry nokid, soSecretmust also be one of theKeys— otherwise nothing validating againstKeysaccepts them.JwtConfigurationValidatorenforces this.
With HS256, every service that validates your tokens holds the secret that signs them, so any of them can mint a token the others accept. ES256 splits the key: a private key signs, and its public half, which you can publish, validates.
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out signing-key.pem
var signingKey = JwtKey.FromEcPrivateKeyPem(File.ReadAllText("signing-key.pem"));
var configuration = new JwtConfiguration(
ExpirationInSeconds: 3600,
Issuer: "my-api",
Audience: "my-app",
Secret: string.Empty,
Claims: new Dictionary<string, string> { { "id", "42" } }
)
{
Algorithm = JwtAlgorithms.ES256,
Keys = [signingKey],
SigningKeyId = signingKey.Id
};
var token = handler.CreateToken(configuration);
var isValid = await handler.IsTokenValidAsync(token, [signingKey.ToPublicKey()]);
An ES256 key’s Id is its RFC 7638 thumbprint, so it is
derived from the key and never configured; tokens carry it as their kid. Rotation works as with
secrets: sign with the new key, keep the old one’s public half in Keys (oldKey.ToPublicKey()) for
one token lifetime, then drop it.
Each configuration pins one algorithm, and each key validates its own algorithm only — a secret
HS256, an EC key ES256 — whatever a token’s alg header says. An HS256 token keyed with your public
key is therefore refused, and so is a key set that mixes both kinds of key (IsTokenValidAsync throws
ArgumentException for one).
// The issuer, e.g. behind GET /.well-known/jwks.json
var json = JwksDocument.From(configuration.Keys.Select(key => key.ToPublicKey()));
// A service validating that issuer's tokens
var keys = JwksDocument.Parse(json);
var isValid = await handler.IsTokenValidAsync(token, keys);
Warning: a valid signature says only that the issuer signed the token, not which audience or purpose it was signed for.
IsTokenValidAsyncchecks the signature and the expiry, so a service verifying an issuer’s key set must also checkissandaud, plus any token-type claim the issuer defines, before it trusts the token: one signing key may sign several kinds of token, such as a user’s session token and a short-lived challenge, and every one of them validates against the same key set. Read those claims from the token once it validates, or hand the keys’ToSecurityKey()forms to a validator such as JwtBearer with issuer and audience validation on.
From writes public halves only. It refuses an HS256 secret, a null key, and a key whose id is not
its thumbprint, such as one renamed with with { Id = ... }, since Parse would drop that entry.
Parse reads EC P-256 signing keys and
skips any entry it cannot use safely: one that is not an EC P-256 signing key, whose kid is not its
thumbprint, or that carries a private part (d) — a published private part means the key is
compromised, so it is never trusted. It returns each key once, skipping an entry whose id repeats an
earlier one, and skips an entry it cannot decode, such as one holding a lone surrogate escape (\uD800). A document that is
not a key set (not JSON, or not an object with a keys array) throws FormatException, and so does the same
undecodable text in the key set’s own member names, e.g. {"keys":[],"\uD800":1}. Cache the
parsed keys, and fetch the set again — at most once a minute — when a token names a kid you do not
know. JwtKey.ToSecurityKey() gives Microsoft.IdentityModel’s form of a key, for validators that use it
directly; pair each key with its own Algorithm only.
var userId = handler.GetUserIdFromToken(token);
var keyId = handler.GetKeyIdFromToken(token);
GetUserIdFromToken and GetKeyIdFromToken read the token without validating it, and return null
for anything they cannot read — an empty string, a token that is not one, a well-shaped token whose
segments are not valid base64url, a token with no id claim, or an id that is not an integer. The
values they return are unverified: validate the token first if either is going to decide anything.
Note: each configuration pins one algorithm,
HS256orES256, and each key validates its own algorithm only — a secret HS256, an EC key ES256 — whatever algorithm the token’s header names; an unsigned token is never accepted. HS256 signs with HMAC-SHA256, which requires a secret of at least 32 bytes (256 bits);JwtConfigurationValidatorrejects a shorter signing secret or key. Secrets are read as ASCII, andEncoding.ASCIIreplaces every character above U+007F with?, so a secret drawn from a wider alphabet contributes far less entropy than its length suggests. Keep secrets to printable ASCII.
Note:
IsTokenValidAsyncchecks the signature and the expiry only, with either overload and either kind of key. It never checks the issuer or the audience, so a token signed with the same key by a different issuer, or minted for a different audience, passes: check those claims yourself. A blank secret (or key) accepts nothing: the method returnsfalserather than throwing.
This project is licensed under the Artur Rios Attribution License. It is free and open source: you may view, use, fork, modify, and extend it, provided you credit the author, Artur Rios, with a link to artur-rios.tech or github.com/artur-rios.
Notable changes in each release.
Building from source, running the tests, branching and releasing.