EonaCat.SecureToken
A cryptographically superior alternative to JWT for .NET 8+
Installation
dotnet add package SecureToken
Quick Start
1. Register with DI (ASP.NET Core)
// Program.cs
using SecureToken.Extensions;
builder.Services.AddSecureTokens(sp =>
{
// Load your key material from environment / secrets manager
var keyBytes = Convert.FromBase64String(Environment.GetEnvironmentVariable("TOKEN_KEY")!);
return SigningKeyStore.FromKeys([(1, keyBytes)]);
});
For development / testing, a random ephemeral key is fine:
builder.Services.AddSecureTokens(); // random key, resets on restart
2. Issue a Token
public class AuthController : ControllerBase
{
private readonly ITokenService _tokens;
public AuthController(ITokenService tokens) => _tokens = tokens;
[HttpPost("login")]
public IActionResult Login([FromBody] LoginRequest req)
{
// ... verify credentials ...
var token = _tokens.Issue(
TokenDescriptor.Create()
.ForSubject(user.Id)
.IssuedBy("my-api")
.ForAudience("my-api")
.WithRole("user")
.WithClaim("email", user.Email)
.ExpiresIn(minutes: 15)
);
return Ok(new { token });
}
}
3. Validate a Token
var result = await _tokens.ValidateAsync(
rawToken,
TokenValidationOptions.AccessToken(issuer: "my-api", audience: "my-api")
);
// Pattern-match the result - no exceptions thrown
switch (result)
{
case TokenResult.Success s:
var userId = s.Claims.Subject;
var roles = s.Claims.Roles;
break;
case TokenResult.Expired e:
return Unauthorized($"Token expired at {e.ExpiredAt}");
case TokenResult.InvalidSignature:
return Unauthorized("Token signature is invalid.");
case TokenResult.WrongTokenType w:
return Unauthorized($"Expected {w.Expected}, got {w.Actual}");
case TokenResult.Revoked r:
return Unauthorized($"Token {r.TokenId} has been revoked.");
default:
return Unauthorized("Token validation failed.");
}
Or use the match helper:
return result.Match(
onSuccess: s => Ok(new { s.Claims.Subject }),
onFailure: err => Unauthorized(err.ToString())
);
Token Pairs (Access + Refresh)
Issue a short-lived access token and a long-lived refresh token in one call:
var pair = _tokens.IssueTokenPair(
subject: user.Id,
issuer: "my-api",
audience: "my-api",
roles: ["admin"],
claims: [new("plan", "pro")],
accessTokenLifetime: TimeSpan.FromMinutes(15),
refreshTokenLifetime: TimeSpan.FromDays(30)
);
return Ok(new
{
accessToken: pair.AccessToken,
refreshToken: pair.RefreshToken,
expiresAt: pair.AccessTokenExpiry,
});
Refresh tokens are type-tagged - they cannot be used where an access token is expected, and vice versa.
// Refreshing
var refreshResult = await _tokens.ValidateAsync(
refreshToken,
TokenValidationOptions.RefreshToken("my-api")
);
if (refreshResult is TokenResult.Success s)
{
var newPair = _tokens.IssueTokenPair(s.Claims.Subject, "my-api", "my-api");
return Ok(newPair);
}
Context Binding
Bind a token to a client context (IP address, device fingerprint, TLS channel hash, etc.). A stolen token from another context will fail validation.
// Issue - bind to the client's IP
var token = _tokens.Issue(
TokenDescriptor.Create()
.ForSubject(user.Id)
.IssuedBy("my-api")
.ForAudience("my-api")
.BoundTo(clientIpAddress) // <-- context binding
);
// Validate - must supply the same context
var result = await _tokens.ValidateAsync(token, new TokenValidationOptions
{
ValidIssuer = "my-api",
ValidAudience = "my-api",
RequiredTokenType = TokenTypeConstants.Access,
BindingContext = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
});
If the IP doesn't match, validation returns TokenResult.BindingMismatch.
Key Rotation
Keys are versioned. Old tokens remain valid after rotation until they expire naturally.
// Inject SigningKeyStore and rotate
var newGeneration = _keyStore.Rotate();
// Or supply new key material from your KMS
var newGeneration = _keyStore.Rotate(myKmsBytes);
// Prune old generation once all tokens from it have expired
_keyStore.PruneGeneration(oldGeneration);
Each token embeds the key generation it was signed with. The verifier automatically picks the right key.
Revocation
Plug in any store - Redis, database, or in-memory:
// Example: Redis-backed revocation
var options = new TokenValidationOptions
{
ValidIssuer = "my-api",
ValidAudience = "my-api",
RequiredTokenType = TokenTypeConstants.Access,
RevocationCheck = async (tokenId, ct) =>
{
return await _redis.KeyExistsAsync($"revoked:{tokenId}");
}
};
// To revoke a token:
var claims = _tokens.Inspect(tokenString);
await _redis.StringSetAsync($"revoked:{claims!.TokenId}", "1", claims.ExpiresAt - DateTimeOffset.UtcNow);
Custom Token Types
Prevent tokens from being used across different purposes:
// Issue an invitation token
var inviteToken = _tokens.Issue(
TokenDescriptor.Create()
.ForSubject(inviteeEmail)
.IssuedBy("my-api")
.OfType(TokenTypeConstants.Invitation) // or your own string, e.g. "workspace-invite+v1"
.WithClaim("workspaceId", workspaceId)
.ExpiresIn(minutes: 60 * 48) // 48 hours
);
// Validate - only accepts invitation tokens
var result = svc.Validate(token, new TokenValidationOptions
{
ValidIssuer = "my-api",
RequiredTokenType = TokenTypeConstants.Invitation,
});
Built-in type constants in TokenTypeConstants:
| Constant | Value |
|||
| Access | at+secure |
| Refresh | rt+secure |
| ServiceAccount | sa+secure |
| Invitation | inv+secure |
| PasswordReset | pwr+secure |
| EmailVerification | ev+secure |
Service Account Tokens
var saToken = _tokens.Issue(
TokenDescriptor.Create()
.ForSubject("svc-worker-1")
.IssuedBy("my-api")
.ForAudience("internal-queue")
.AsServiceAccount()
.WithRole("queue-publisher")
.WithLifetime(TimeSpan.FromDays(365))
);
Full Validation Options Reference
var options = new TokenValidationOptions
{
// Issuer the token must have been issued by
ValidIssuer = "my-api",
// Audience the token must include
ValidAudience = "my-api",
// Token type that must match exactly
RequiredTokenType = TokenTypeConstants.Access,
// Context binding (IP, device hash, etc.)
BindingContext = clientIp,
// Clock skew tolerance (default: 30 seconds)
ClockSkew = TimeSpan.FromSeconds(30),
// Toggle individual checks
ValidateExpiry = true,
ValidateNotBefore = true,
// Async revocation check - skip for sync validation
RevocationCheck = async (tokenId, ct) =>
await _revokedIds.ContainsAsync(tokenId, ct),
};
Inspecting Tokens (Debug Only)
// No signature check - for diagnostics, never for authorization
var claims = _tokens.Inspect(rawToken);
Console.WriteLine($"Subject: {claims?.Subject}");
Console.WriteLine($"Issued: {claims?.IssuedAt}");
Console.WriteLine($"Expires: {claims?.ExpiresAt}");
Console.WriteLine($"KeyGen: {claims?.KeyGeneration}");
Token Format
stv1.{base64url(payload)}.{base64url(signature)}
stv1- version prefix, never changes algorithm.payload- compact binary-encoded claims (not JSON).signature- HMAC-SHA256 over the payload using an HKDF-derived sub-key.
The sub-key is derived from the master key using HKDF with the token type as context, so a signing key used for access tokens cannot verify refresh tokens even if an attacker switches the type tag.
Security Properties
- No algorithm field in token - the verifier always decides the algorithm.
- Binary payload - no JSON parser, no prototype pollution, no ambiguous number types.
- Type-segregated sub-keys - HKDF derives a separate key per token type.
- Constant-time comparison - signature verification uses
CryptographicOperations.FixedTimeEquals. - Typed results, no exceptions - validation never throws; every failure is a named case.
- Context binding - tokens can be tied to an IP, device, or TLS channel binding.
- Versioned key rotation - rotate keys without invalidating unexpired tokens.