2026-06-19 16:34:50 +02:00
2026-06-19 16:34:50 +02:00
2026-06-19 16:34:50 +02:00
2026-06-19 16:34:50 +02:00
2026-06-19 16:34:50 +02:00
2026-06-19 16:34:50 +02:00
2026-06-19 16:34:50 +02:00
2026-06-19 16:34:50 +02:00
2026-06-19 16:34:50 +02:00

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.
S
Description
Languages
C# 100%