Ana içeriğe geç

OrionLedger

OrionLedger

CI/CD NuGet

API key lifecycle for .NET. Issue prefixed, high-entropy keys; store only their hash; verify a presented key against prefix, hash, expiry, revocation, and scope; revoke keys; and track when each key was last used.

Part of the Orion family. Usable entirely on its own.

Note on the name. Despite "Ledger", this is not a financial or accounting ledger. It keeps a ledger of issued API keys: the set of keys that exist, their scopes, and their issue, expiry, revoke, and last-used state. There is no money, balance, or double-entry bookkeeping anywhere in the package.

Why​

Rolling your own API keys invites two classic mistakes: storing the key in a form an attacker who reads your database can use, and treating verification as a string compare that ignores expiry, revocation, and scope. OrionLedger generates keys the way payment providers do (a recognisable prefix plus 256 bits of randomness), stores only a SHA-256 hash, and resolves verification to a single status you can switch on.

How it works​

Issuance generates a plaintext token once, hashes it, and persists only the record. Verification walks a fixed sequence of checks and collapses to a single ApiKeyStatus. The plaintext token never touches storage.

Issue: name + scopes -> generate(prefix + 256-bit secret) -> hash -> store record -> return token ONCE

Verify: token -> prefix check -> hash lookup -> revoked? -> expired? -> scope? -> Valid (stamp last-used)
| | | | |
Malformed NotFound Revoked Expired MissingScope

Every branch except Malformed and NotFound returns the matched record, so you can log which key was rejected and why.

Features​

  • Prefixed, high-entropy tokens. prefix + base64url(secret); 32 random bytes (256 bits) by default, configurable down to a 16-byte floor. The prefix makes leaked keys recognisable in logs.
  • Hash at rest. Only the SHA-256 hash of a token is stored. The plaintext is returned once at issuance and is unrecoverable afterwards.
  • Single-status verification. One call resolves prefix, hash lookup, revocation, expiry, and an optional required scope into one ApiKeyStatus.
  • Scopes. Grant scopes at issuance; require one at verification.
  • Expiry. Per-key explicit expiry, or a configured default lifetime, or no expiry.
  • Revocation. Revoke by id; revoked keys verify as Revoked. Revoke every active key for a subject in one call with RevokeAllForSubjectAsync.
  • Rotation. Issue a fresh successor key with RotateAsync, with an optional grace window during which the old key keeps verifying before it retires.
  • Last-used tracking. A successful verification stamps LastUsedAt and increments LastUsedCount on the record.
  • Pluggable storage. Ships with an in-memory store; implement IApiKeyStore over your database to persist and share keys. Bulk revoke by subject needs the optional FindBySubjectAsync override.
  • Telemetry and audit. An OpenTelemetry meter plus a fault-safe lifecycle observer.
  • Constant-time hash comparison helper for callers that compare hashes directly.
  • Multi-targets net8.0, net9.0, net10.0; nullable enabled; warnings as errors.

A deeper breakdown lives in docs/FEATURES.md.

Install​

dotnet add package OrionLedger

Quick start​

Register the service and configure issuance:

builder.Services.AddOrionLedger(o =>
{
o.Prefix = "ork_live_";
o.DefaultLifetime = TimeSpan.FromDays(90); // optional
});

Issue a key. The plaintext token exists exactly once, here; store nothing but the record:

app.MapPost("/keys", async (IApiKeyService keys) =>
{
var issued = await keys.IssueAsync("Acme Corp", scopes: ["orders:read", "orders:write"]);
return Results.Ok(new { apiKey = issued.Token }); // the ONLY time the token is plaintext
});

Verify a presented key:

var result = await keys.VerifyAsync(presentedToken, requiredScope: "orders:write");
if (!result.IsValid)
{
return result.Status switch
{
ApiKeyStatus.MissingScope => Results.StatusCode(403),
_ => Results.StatusCode(401),
};
}

var tenant = result.Record!.Name;

Usage​

Scopes​

Pass the scopes a key should carry to IssueAsync, and the scope a request requires to VerifyAsync. Scope matching is exact, ordinal, and case-sensitive.

var issued = await keys.IssueAsync("reporting-job", scopes: ["reports:read"]);

var ok = await keys.VerifyAsync(token, requiredScope: "reports:read"); // Valid
var no = await keys.VerifyAsync(token, requiredScope: "reports:write"); // MissingScope
var any = await keys.VerifyAsync(token); // skips the scope check

requiredScope: null (the default) skips the scope check entirely; the key is still validated for prefix, existence, revocation, and expiry.

Expiry​

Expiry resolves in this order: an explicit expiresAt on IssueAsync, else the configured DefaultLifetime added to the issue time, else the key never expires.

// Explicit expiry on a single key.
await keys.IssueAsync("temp-integration", expiresAt: DateTimeOffset.UtcNow.AddHours(1));

// No explicit expiry: DefaultLifetime applies if configured, otherwise the key never expires.
await keys.IssueAsync("service-account");

A key past its expiry verifies as ApiKeyStatus.Expired, and the matched record is returned so you can log it.

Revocation​

Revoke by the record id assigned at issuance. RevokeAsync returns true when it revoked an active key, and false if no such key exists or it was already revoked (so the call is idempotent).

var issued = await keys.IssueAsync("Acme Corp");
string keyId = issued.Record.Id;

bool revoked = await keys.RevokeAsync(keyId); // true
bool again = await keys.RevokeAsync(keyId); // false (already revoked)

After revocation the key verifies as ApiKeyStatus.Revoked.

Rotation​

RotateAsync issues a fresh successor key (new id, secret, and hash) that inherits the predecessor's name, subject, scopes, and expiry, then supersedes the predecessor. The new plaintext token is returned once on KeyRotation.Token (a shortcut to Successor.Token) and never again, exactly like a fresh issuance.

With a positive grace, the old key keeps verifying for that window so callers presenting the old token have time to migrate, then resolves as ApiKeyStatus.Retired. With a null or zero grace the predecessor is revoked immediately. Rotating a key that is missing, revoked, expired, or already superseded returns null.

var rotation = await keys.RotateAsync(keyId, grace: TimeSpan.FromMinutes(5));
if (rotation is null)
{
return Results.NotFound(); // missing, revoked, expired, or already rotated
}

var newToken = rotation.Token; // show once, then store nothing but the record
var successorId = rotation.Successor.Record.Id;
var retiresAt = rotation.Predecessor.RetiresAt; // when the old token stops verifying

During the grace window both the old and new tokens verify as Valid. Once the window elapses the old token verifies as ApiKeyStatus.Retired.

Last-used tracking​

Each successful verification stamps LastUsedAt and increments LastUsedCount on the record.

var result = await keys.VerifyAsync(presentedToken);
DateTimeOffset? lastUsed = result.Record!.LastUsedAt;
long useCount = result.Record!.LastUsedCount;

LastUsedCount is a best-effort usage signal, not an exact ledger: concurrent verifications of the same key may race on the non-atomic increment and lose counts. A store needing an exact total should compute it durably (for example an atomic database increment).

Bulk revoke by subject​

A key may be issued with a subject (the owner it belongs to, for example a user, tenant, or service id). RevokeAllForSubjectAsync revokes every active key for that subject in one call and returns the number of keys it newly revoked. Already revoked, expired, and retired keys are skipped.

await keys.IssueAsync("Acme web", subject: "tenant-42");
await keys.IssueAsync("Acme worker", subject: "tenant-42");

int revoked = await keys.RevokeAllForSubjectAsync("tenant-42"); // 2

Bulk revoke requires the store to support lookup by subject (IApiKeyStore.FindBySubjectAsync); the in-memory store does. A store that does not throws NotSupportedException.

Custom store​

The default InMemoryApiKeyStore is process-local: it does not survive a restart and is not shared across instances. For a ready-made durable store, add OrionLedger.EntityFrameworkCore, which implements IApiKeyStore over EF Core (it indexes the hash, persists every lifecycle field, and increments last-used atomically). To roll your own instead, implement IApiKeyStore over your database and register it before AddOrionLedger(). The in-memory store is only added if no IApiKeyStore is already present.

If you write a custom store, the OrionLedger.Conformance package provides a reusable xUnit suite that checks it against the IApiKeyStore contract.

public sealed class SqlApiKeyStore : IApiKeyStore
{
public Task AddAsync(ApiKeyRecord record, CancellationToken ct = default) { /* INSERT */ }
public Task<ApiKeyRecord?> FindByHashAsync(string hash, CancellationToken ct = default) { /* by hash */ }
public Task<ApiKeyRecord?> FindByIdAsync(string id, CancellationToken ct = default) { /* by id */ }
public Task UpdateAsync(ApiKeyRecord record, CancellationToken ct = default) { /* UPDATE */ }

// Optional: only needed for RevokeAllForSubjectAsync. Without it bulk revoke throws
// NotSupportedException; the rest of the lifecycle works unchanged.
public Task<IReadOnlyList<ApiKeyRecord>> FindBySubjectAsync(string subject, CancellationToken ct = default) { /* by subject */ }
}

// Registration order matters: register the store first.
builder.Services.AddSingleton<IApiKeyStore, SqlApiKeyStore>();
builder.Services.AddOrionLedger(o => o.Prefix = "ork_live_");

The hash is the verification lookup key; index it. UpdateAsync persists the mutable lifecycle fields: RevokedAt, LastUsedAt, LastUsedCount, and the rotation timestamps (SupersededAt, SupersededById, RetiresAt). FindBySubjectAsync is a default interface method, so stores written against 0.1.0 keep compiling; override it only to enable bulk revoke by subject.

ASP.NET Core​

For an ASP.NET Core host, add OrionLedger.AspNetCore. It ships an authentication handler that reads the key from a configurable header (default X-Api-Key), verifies it through OrionLedger, and on success builds a ClaimsPrincipal carrying the key's subject and scopes. Scopes are projected into claims, so a standard authorization policy can require a scope without the endpoint re-checking it.

builder.Services.AddOrionLedger();

builder.Services
.AddAuthentication(ApiKeyAuthenticationOptions.DefaultScheme)
.AddOrionLedgerApiKey();

builder.Services.AddAuthorization(options =>
options.AddApiKeyScopePolicy("orders-read", "orders:read"));

app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/orders", () => "ok").RequireAuthorization("orders-read");

A revoked, expired, or unknown key fails authentication with no principal (the framework returns 401); a valid key that lacks the required scope is forbidden (403). See the package README for the full set of options and the require-all / require-any helpers.

Configuration​

Configure issuance through ApiKeyOptions in the AddOrionLedger callback. Options are validated when the service is built; an invalid value throws at startup.

OptionTypeDefaultNotes
Prefixstringork_Prepended to every token. Must not be empty. A trailing underscore is conventional.
SecretByteLengthint32 (256-bit)Random bytes in the secret. Must be at least 16.
DefaultLifetimeTimeSpan?nullApplied when a key is issued without an explicit expiry. Must be positive when set. null means keys do not expire by default.

Verification statuses​

VerifyAsync returns an ApiKeyVerification with a Status, an IsValid shortcut (true only for Valid), and the matched Record where one applies.

StatusMeaningRecord returned
ValidKnown, active, unexpired, scope present; last-used updatedYes
MalformedEmpty, or missing the configured prefixNo
NotFoundNo key with this token hashNo
ExpiredPast its expiryYes
RevokedRevokedYes
RetiredRotated, and its grace window has elapsedYes
MissingScopeOtherwise valid but lacks the required scopeYes

For every status except Malformed and NotFound the matched record is returned, so you can log which key was rejected.

Security notes​

  • Hash at rest. The plaintext token is returned once from IssueAsync and never stored. Storage holds only the SHA-256 hash (a 64-character lowercase hex digest) and a short non-secret display prefix. A reader of your database cannot reconstruct a usable key.
  • Why a fast hash is correct here. API keys are full-entropy random tokens (256 bits by default), so there is no low-entropy secret to defend against brute force, and a slow password hash (bcrypt/Argon2) would only add latency to the verification hot path. SHA-256 is the right tool: O(1) verification, nothing to grind. Do not reuse this reasoning for user passwords.
  • Constant-time comparison. ApiKeyHasher.FixedTimeEquals wraps CryptographicOperations.FixedTimeEquals so a direct hash comparison does not leak, through timing, how many leading characters matched. The default verification path looks keys up by hash in O(1) rather than comparing pairwise, so it does not depend on this; the helper is provided for callers that compare hashes themselves.
  • Show the token once. IssuedApiKey.Token is the only point at which the plaintext exists. Surface it to the caller immediately and keep only IssuedApiKey.Record.

Telemetry and diagnostics​

OrionLedger publishes an OpenTelemetry meter named Moongazing.OrionLedger (ApiKeyDiagnostics.MeterName) with four instruments:

InstrumentKindTagsMeaning
orion.ledger.keys.issuedcounter-Keys issued
orion.ledger.verificationscounterstatusVerification attempts, tagged with the status tag (valid, malformed, not_found, expired, revoked, retired, missing_scope)
orion.ledger.keys.revokedcounter-Keys revoked
orion.ledger.keys.rotatedcounter-Keys rotated

Subscribe with the usual OpenTelemetry metrics pipeline:

builder.Services.AddOpenTelemetry()
.WithMetrics(m => m.AddMeter(ApiKeyDiagnostics.MeterName));

For an audit trail, register an IApiKeyEventObserver; it is notified on issue, verify, and revoke. The observer is fault-safe: an exception it throws is swallowed and never blocks the lifecycle operation, because audit logging must not be load-bearing.

public sealed class AuditObserver : IApiKeyEventObserver
{
public void OnIssued(ApiKeyRecord record) { /* log issue */ }
public void OnVerified(ApiKeyVerification verification) { /* log every attempt */ }
public void OnRevoked(ApiKeyRecord record) { /* log revoke */ }
}

builder.Services.AddSingleton<IApiKeyEventObserver, AuditObserver>();

Testing​

The library is built to be tested without a database: register your own IApiKeyStore or use the in-memory store directly, and construct ApiKeyService yourself in unit tests. The internal constructor accepts injected clock and id factories (exposed to the test assembly), so expiry and identifier behaviour are deterministic.

Run the suite from the repository root:

dotnet test

Benchmarks for the hot paths (generation, hashing, constant-time comparison, end-to-end lifecycle) live in benchmarks/Moongazing.OrionLedger.Benchmarks. See benchmarks.md for what is measured and how to run it. Results are intentionally not committed, since they depend on the host machine.

Versioning​

OrionLedger follows semantic versioning. The packages multi-target net8.0, net9.0, and net10.0. The current line is 0.5.0; while the major version is 0, the public surface may still change between minor versions. Notable changes are recorded in CHANGELOG.md.

Contributing​

Issues and pull requests are welcome. Please read CONTRIBUTING.md and the Code of Conduct before opening one. Direction and ideas under consideration are in docs/ROADMAP.md.

More from the Orion family​

Focused .NET libraries built to one quality bar. Each is usable on its own; several share the small Orion.Abstractions contracts spine, but there is no deep dependency web — pick only what you need:

  • OrionGuard — validation, guard clauses, DDD primitives, domain events
  • Orion.Abstractions — the shared contracts spine: telemetry, options, result, clock
  • OrionAudit — automatic EF Core change-audit trail
  • OrionBeacon — leader election with fencing tokens
  • OrionClock — testable time, TTLs, and deadlines
  • OrionGrant — permission / authorization checks
  • OrionKey — source-generated strongly-typed IDs
  • OrionLens — ambient correlation-context propagation
  • OrionLock — distributed locks with fencing tokens
  • OrionOnce — idempotency keys for exactly-once request handling
  • OrionPatch — transactional outbox for EF Core
  • OrionRelay — outbound webhook delivery (HMAC, retries, backoff)
  • OrionResult — Result/Option types and a shared error vocabulary
  • OrionSaga — sagas / process managers for long-running workflows
  • OrionShade — sensitive-data redaction for logs and telemetry
  • OrionStream — server-sent events / streaming hub
  • OrionVault — field-level encryption for EF Core

See it all working together in OrionShowcase, a production-shaped banking sample.

License​

Licensed under the MIT License.

Author​

Tunahan Ali Ozturk - GitHub

Packages​

PackageVersionDownloads
OrionLedger0.5.0814
OrionLedger.AspNetCore0.5.0241
OrionLedger.EntityFrameworkCore0.5.0239
OrionLedger.Conformance0.5.0239