Files
EonaCat.SecureToken/README.md
T
2026-06-20 06:27:57 +02:00

7.4 KiB

EonaCat.SecureToken

Secure, modern token library for .NET with key rotation, signing isolation, validation rules, and .NET Standard support.

EonaCat.SecureToken provides a safer alternative to rolling your own authentication tokens. It focuses on:

  • Strong cryptographic signing
  • Versioned key management
  • Token lifecycle validation
  • Refresh/access token separation
  • Extensible claims
  • API-friendly validation results
  • .NET Standard 2.0 compatibility

Features

Cryptographic protection

  • HMAC based token signing
  • HKDF derived context-specific keys
  • Constant-time signature verification (including binding-context comparison)
  • Tamper detection
  • Strong random key generation
  • Bounded decoding: oversized or field-flooded tokens are rejected before signature verification, so malformed input cannot be used to exhaust memory

Validation

  • Issuer, audience (single or multi-audience accept-list), and token-type checks
  • Expiry, not-before, and an independent MaxTokenAge backstop measured from issuance
  • Token binding (IP, device fingerprint, TLS channel hash, etc.)
  • Pluggable revocation check
  • Pluggable replay-cache for one-time-use tokens (password reset, email verification, invitations)
  • OnValidated audit hook fired for every validation attempt

Key rotation

Rotate signing keys without invalidating existing tokens.

Example:

var store = SigningKeyStore.CreateNew();

var service = new TokenService(store);

var oldToken = service.Issue(
    TokenDescriptor.Create()
        .ForSubject("user-123")
        .IssuedBy("my-api")
        .ForAudience("mobile")
);

// Rotate keys
store.Rotate();

// New tokens use the new key
var newToken = service.Issue(
    TokenDescriptor.Create()
        .ForSubject("user-456")
        .IssuedBy("my-api")
        .ForAudience("mobile")
);

// Old token still validates
service.Validate(
    oldToken,
    TokenValidationOptions.AccessToken("my-api", "mobile")
);

Installation

Install from NuGet:

dotnet add package EonaCat.SecureToken

Quick Start

Create a token service

using EonaCat.SecureToken.Core;
using EonaCat.SecureToken.Cryptography;

var keys = SigningKeyStore.CreateNew();

var tokens = new TokenService(keys);

Issue an access token

var token = tokens.Issue(
    TokenDescriptor.Create()
        .ForSubject("user-123")
        .IssuedBy("my-service")
        .ForAudience("api")
        .WithRole("admin")
        .WithClaim("email", "user@example.com")
);

The token contains:

  • Subject
  • Issuer
  • Audience
  • Roles
  • Custom claims
  • Token ID
  • Expiration
  • Key generation information

Validate a token

var result = tokens.Validate(
    token,
    TokenValidationOptions.AccessToken(
        issuer: "my-service",
        audience: "api"
    )
);

if (result.IsSuccess)
{
    var claims = result.UnwrapClaims();

    Console.WriteLine(claims.Subject);
}

Token expiration

var token = tokens.Issue(
    TokenDescriptor.Create()
        .ForSubject("user-1")
        .IssuedBy("api")
        .ForAudience("mobile")
        .WithLifetime(TimeSpan.FromMinutes(15))
);

Expired tokens are automatically rejected.

Refresh tokens

Create a refresh token:

var pair = tokens.IssueTokenPair(
    "user-1",
    "api",
    "mobile"
);

Console.WriteLine(pair.AccessToken);
Console.WriteLine(pair.RefreshToken);

Validate separately:

tokens.Validate(
    pair.RefreshToken,
    TokenValidationOptions.RefreshToken("api")
);

Refresh tokens cannot be used as access tokens.

Token binding

Bind tokens to a context such as a device or session:

var token = tokens.Issue(
    TokenDescriptor.Create()
        .ForSubject("user-1")
        .IssuedBy("api")
        .ForAudience("web")
        .BoundTo("device-identifier")
);

Validation:

new TokenValidationOptions
{
    ValidIssuer = "api",
    ValidAudience = "web",
    BindingContext = "device-identifier"
};

Revocation

You can integrate your own revocation storage:

var options = new TokenValidationOptions
{
    ValidIssuer = "api",
    ValidAudience = "web",

    RevocationCheck = async (tokenId, cancellationToken) =>
    {
        return await database.IsRevoked(tokenId);
    }
};

Replay protection for one-time-use tokens

Revocation answers "has someone explicitly blocked this token?" Replay protection answers a different question: "has this exact token already been used once?" Use it for password-reset links, email-verification links, and invitations - anything that should only ever be redeemed a single time, even before it expires.

var replayCache = new InMemoryReplayCache(); // register as a singleton in DI

var options = TokenValidationOptions.OneTimeUse(
    issuer: "api",
    tokenType: TokenTypeConstants.PasswordReset,
    maxAge: TimeSpan.FromMinutes(15));

options.ReplayCache = replayCache;

var result = await tokens.ValidateAsync(token, options);
// Second call with the same token returns TokenResult.Replayed instead of Success.

InMemoryReplayCache is process-local and fine for a single instance. For multi-instance deployments, implement IReplayCache against a shared store (Redis, or a database table with a unique constraint on the token ID) so replay detection works across all instances.

Replay consumption only happens through ValidateAsync, since it has a side effect (recording the token as used) - the synchronous Validate never touches the replay cache.

Multiple audiences

Use ValidAudiences when the same access token needs to be accepted by more than one downstream service:

var options = TokenValidationOptions.AccessToken(
    issuer: "api",
    audiences: new[] { "service-a", "service-b", "service-c" });

The token is accepted if any one of its own audiences matches any one of the configured set.

Audit and introspection hook

OnValidated is invoked for every validation attempt, success or failure, and is intended for audit logging or metrics - not for authorization decisions:

var options = TokenValidationOptions.AccessToken("api", "web");

options.OnValidated = e =>
{
    logger.LogInformation("Token validation: {Result} sub={Subject}", e.Result, e.Subject);
};

Subject and TokenId on the event are only populated when the result is a success, since claims are never trusted before the signature has verified.

ASP.NET Core dependency injection

builder.Services.AddSecureTokens();

or provide your own key store:

builder.Services.AddSecureTokens(
    store =>
    {
        return SigningKeyStore.FromKeys(
            new[]
            {
                (1, secretKeyBytes)
            });
    });

To enable replay protection via DI:

builder.Services.AddSecureTokenReplayProtection();

Security design

The library separates cryptographic purposes:

Master Key
    |
    +-- Signing Key
    |
    +-- Encryption Key
    |
    +-- Context-specific keys

This prevents accidental key reuse between operations.

Supported frameworks

  • .NET Standard 2.0
  • .NET Standard 2.1
  • .NET Framework 4.8
  • .NET 8+

When to use

Good fit for:

APIs
Microservices
Internal authentication
Service-to-service tokens
Applications needing key rotation

When not to use

Do not store secrets directly in source code.

Use:

  • Environment variables
  • Secret managers
  • Hardware-backed key storage where required

License

Apache License.