PostQuantum.AspNetCore 1.0.0-preview.3

This is a prerelease version of PostQuantum.AspNetCore.
dotnet add package PostQuantum.AspNetCore --version 1.0.0-preview.3
                    
NuGet\Install-Package PostQuantum.AspNetCore -Version 1.0.0-preview.3
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="PostQuantum.AspNetCore" Version="1.0.0-preview.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="PostQuantum.AspNetCore" Version="1.0.0-preview.3" />
                    
Directory.Packages.props
<PackageReference Include="PostQuantum.AspNetCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add PostQuantum.AspNetCore --version 1.0.0-preview.3
                    
#r "nuget: PostQuantum.AspNetCore, 1.0.0-preview.3"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package PostQuantum.AspNetCore@1.0.0-preview.3
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=PostQuantum.AspNetCore&version=1.0.0-preview.3&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=PostQuantum.AspNetCore&version=1.0.0-preview.3&prerelease
                    
Install as a Cake Tool

PostQuantum.AspNetCore

NuGet CI License .NET

The high-level ASP.NET Core integration for post-quantum JWT authentication. Add one line — AddPostQuantumJwtBearer(…) — and hybrid ML-DSA-65 + X-Wing tokens authenticate through the standard AuthenticationBuilder exactly the way AddJwtBearer always has. [Authorize] attributes, policies, role checks, claims, middleware — everything downstream of the wireup works unchanged, because the handler emits a real ClaimsPrincipal.

Built on PostQuantum.Jwt and the native .NET 10 BCL post-quantum primitives. Fail-closed by construction. Small surface. Honest about its limits.

What this package is: a thin, opinionated application layer for ASP.NET Core authentication. Extension methods, an AuthenticationHandler, event hooks, a JWKS-equivalent key ring, a hosted-service warmup, and metrics + tracing — all the wiring you'd otherwise write yourself to make post-quantum JWTs feel native to AddAuthentication.

What this package is not: a cryptography library. No implementation of ML-DSA, ML-KEM, X25519, AES-GCM, or SHA-3 lives in here. We don't compete with BouncyCastle, liboqs, or System.Security.Cryptography. The actual signing, verification, key encapsulation, and content encryption all happen inside PostQuantum.Jwt, which in turn uses the FIPS-validated .NET 10 BCL post-quantum primitives (with BouncyCastle for the one piece the BCL doesn't ship: X25519). Think of PostQuantum.AspNetCore as the AddJwtBearer equivalent that knows the right things about ML-DSA-65 — not a reinvention of the crypto stack underneath.

Status — 1.0.0-preview.3. Preview software. Not for production use. The API may change before 1.0, and the underlying cryptographic construction has not been independently audited. Read KNOWN-GAPS.md before depending on this for anything that matters.

Most ASP.NET Core apps should use this package. It is the high-level, one-line wire-up for post-quantum JWT bearer auth — event hooks, hosted- service warmup, Meter + ActivitySource observability, distributed replay-cache wiring, and a DI helper that doesn't force a BuildServiceProvider() call. The engine repository also ships a lower- level PostQuantum.Jwt.AspNetCore package; that one is the minimal "prove the engine works in ASP.NET Core" surface and lacks those additions. Pick it only if you are deliberately building your own application-layer wiring on top of the engine.

Operator note: the HTTP key directory is the root of trust for token validation. Read SECURITY.md#trust-root-the-http-key-directory before shipping — operators are expected to configure certificate pinning or a hardened HttpClient on the directory endpoint. The library has no insecure fallback by design; if the fetch fails closed, validation fails closed.

Highlights

  • One-line wireupAddPostQuantumJwtBearer(…) slots into the standard AuthenticationBuilder exactly like AddJwtBearer.
  • Fail-closed by construction — every validation failure becomes 401. No alg: none, no algorithm fallback, no degraded path.
  • Distributed replay protection — single-use jti enforcement across your fleet via the PostQuantum.AspNetCore.RedisReplayCache companion package (SET NX + remaining-token-TTL).
  • JWKS-equivalent key rotationIPostQuantumJwtKeyRing with an HTTP-backed implementation, atomic snapshot swap on refresh, unknown-kid throttling, hosted-service startup warmup.
  • Four event hooksOnMessageReceived (SignalR-style alternate token transports), OnTokenValidated (enrich principal), OnAuthenticationFailed, OnChallenge.
  • First-class observabilitySystem.Diagnostics.Metrics + an ActivitySource for OpenTelemetry / Prometheus / Application Insights.
  • AOT-compatibleIsAotCompatible=true, verified end-to-end in CI on Linux, Windows, and macOS.
  • Honest about limits — preview status, non-IANA algorithm identifiers, no independent audit, every gap tracked in KNOWN-GAPS.md.

In a hurry? Jump straight to:

  • Getting started — zero to working PQ API in 10 minutes.
  • Migrating from AddJwtBearer — side-by-side diff.
  • Security model — what the library protects, what it doesn't, replay-protection requirements.
  • Recipes — copy-paste-able scenarios: Redis replay, OpenTelemetry, SignalR, multi-tenant, multi-scheme, Swagger, Docker/K8s.
  • FAQ — should I use this in production? how big are tokens? does it work with Auth0? — and 15 more.
  • Production checklist — before user traffic hits.

Where does this fit in the stack?

┌──────────────────────────────────────────────────────────────────────┐
│  Your ASP.NET Core app                                               │
│  builder.Services.AddAuthentication().AddPostQuantumJwtBearer(...)   │
├──────────────────────────────────────────────────────────────────────┤
│  PostQuantum.AspNetCore                                  (this lib)  │
│  · AuthenticationHandler + options + 4 event hooks                   │
│  · IPostQuantumJwtKeyRing (JWKS-equivalent)                          │
│  · Hosted-service warmup, metrics, tracing                           │
├──────────────────────────────────────────────────────────────────────┤
│  PostQuantum.Jwt                          (the engine, separate pkg) │
│  · PqJwtBuilder / PqJwtValidator                                     │
│  · X-Wing combiner, JWE wire format, replay cache                    │
├──────────────────────────────────────────────────────────────────────┤
│  Crypto primitives                                  (not this lib)   │
│  · System.Security.Cryptography.MLDsa / MLKem  (.NET 10 BCL)         │
│  · BouncyCastle.Cryptography                   (X25519 only)         │
└──────────────────────────────────────────────────────────────────────┘

This library sits at the top of that stack — the application integration layer. It does no cryptography of its own. If you're looking for raw ML-DSA, ML-KEM, X25519, or AES-GCM, those live in the .NET BCL and BouncyCastle and we are happy customers, not competitors.

Table of contents


Why

Why a separate package, when you could just call PostQuantum.Jwt yourself from your ASP.NET Core app? Because authentication wiring is where the bugs live. Token retrieval from Authorization (or ?access_token= for SignalR), case-insensitive Bearer prefix matching, the WWW-Authenticate challenge response with RFC-compliant realm escaping, event hooks for principal enrichment, key-ring rotation, hosted-service cache warmup, fail-closed handling of every exception path, metrics for ops dashboards, distributed-tracing spans — Microsoft.AspNetCore.Authentication.JwtBearer does all of that for the classical algorithms. This library does it for ML-DSA-65. You shouldn't have to write the wiring yourself.

Why post-quantum at all? A cryptographically relevant quantum computer would break the elliptic-curve math behind every JWT signature in production today (EdDSA, ECDSA, RSA). Pure post-quantum schemes are new and comparatively under-attacked. Hybrid hedges both at once:

  • Signatures — ML-DSA-65 (FIPS 204). NIST-standardised lattice signature, security category 3.
  • Key agreement — X-Wing. The IETF hybrid KEM combining X25519 with ML-KEM-768 (FIPS 203), bound together by a SHA3-256 combiner. An attacker must break both to recover the key.

Microsoft.AspNetCore.Authentication.JwtBearer is the right choice for the vast majority of JWT work today — it speaks the entire IANA JOSE algorithm catalogue and has been hardened over a decade of production use. But Microsoft.IdentityModel does not understand ML-DSA-65, and shimming a post-quantum algorithm into a token validator that wasn't designed for it is the wrong shape of problem. PostQuantum.AspNetCore bypasses that path entirely: a fail-closed AuthenticationHandler that delegates to PqJwtValidator and nothing else.


Install

dotnet add package PostQuantum.AspNetCore --version 1.0.0-preview.3

Or in a .csproj:

<PackageReference Include="PostQuantum.AspNetCore" Version="1.0.0-preview.3" />

Runtime requirement: the native ML-KEM / ML-DSA primitives need an OpenSSL build that exposes them — OpenSSL 3.5 or later on Linux, or a recent Windows. Where they are unavailable, the underlying PostQuantum.Jwt engine fails closed with a clear error rather than silently falling back to weaker crypto.


60-second tour

using PostQuantum.AspNetCore;
using PostQuantum.Jwt;
using System.Security.Cryptography;

var builder = WebApplication.CreateBuilder(args);

using var verificationKey = MLDsa.ImportMLDsaPublicKey(
    MLDsaAlgorithm.MLDsa65,
    Convert.FromBase64String(builder.Configuration["Auth:VerificationKey"]!));

builder.Services
    .AddAuthentication(PostQuantumJwtBearerDefaults.AuthenticationScheme)
    .AddPostQuantumJwtBearer(options =>
    {
        options.ValidationParameters = new PqJwtValidationParameters
        {
            SignatureVerificationKey = verificationKey,
            ValidIssuer   = builder.Configuration["Auth:Issuer"],
            ValidAudience = builder.Configuration["Auth:Audience"],
        };
    });
builder.Services.AddAuthorization();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/me", (HttpContext ctx) => new
{
    sub  = ctx.User.FindFirst("sub")?.Value,
    role = ctx.User.FindFirst("role")?.Value,
}).RequireAuthorization();

app.Run();

That's the whole integration. The handler is fail-closed by construction (tampered, expired, or wrong-issuer tokens produce AuthenticateResult.Fail), RequireAuthorization() returns 401 to unauthenticated callers, and standard [Authorize(Roles = "...")] attributes work against the "role" claim.

⚠️ Before you ship this to production, add one more line for distributed replay protection — without it, captured tokens are reusable until they expire:

// dotnet add package PostQuantum.AspNetCore.RedisReplayCache
builder.Services.AddPostQuantumJwtRedisReplayCache(
    builder.Configuration["Redis:ConnectionString"]!);

See the headline section below and the full Security model for the deployment-shape matrix.

A runnable end-to-end version of this — issuer endpoint, protected endpoint, ephemeral key pair — lives in samples/PostQuantum.AspNetCore.Demo.

dotnet run --project samples/PostQuantum.AspNetCore.Demo
# in another shell
TOKEN=$(curl -s -X POST http://localhost:5000/dev/token | jq -r .token)
curl -H "Authorization: Bearer $TOKEN" http://localhost:5000/me

A second sample — samples/PostQuantum.AspNetCore.SignalR.Demo — exercises the OnMessageReceived event end-to-end against a real SignalR hub with the canonical ?access_token= connection pattern, plus an in-page browser client so the whole loop runs in one process:

dotnet run --project samples/PostQuantum.AspNetCore.SignalR.Demo
# browse to http://localhost:5050/

A third sample — samples/PostQuantum.AspNetCore.Mvc.Demo — shows the classic controller-based ASP.NET Core MVC pattern: [Authorize], [Authorize(Roles = "admin")], and [Authorize(Policy = "AcmeTenant")] against PQ tokens, with an in-page browser harness that mints and exercises tokens against each endpoint:

dotnet run --project samples/PostQuantum.AspNetCore.Mvc.Demo
# browse to http://localhost:5100/

Usage

Sign and validate

The handler validates whatever PqJwtValidator accepts — single-key, issuer-and-audience pinned, with optional replay defence:

builder.Services
    .AddAuthentication(PostQuantumJwtBearerDefaults.AuthenticationScheme)
    .AddPostQuantumJwtBearer(options =>
    {
        options.ValidationParameters = new PqJwtValidationParameters
        {
            SignatureVerificationKey = verificationKey,
            ValidIssuer   = "https://issuer.example",
            ValidAudience = "https://api.example",
            // Single-process replay defence. Swap to a Redis-backed
            // IPqJwtReplayCache for a horizontally scaled deployment.
            ReplayCache = new InMemoryReplayCache(),
        };
    });

Token minting lives in PostQuantum.Jwt itself — PqJwtBuilder — and is not duplicated here. This package is the receiving half.

Events: enrich, observe, customize the challenge

PostQuantumJwtBearerEvents mirrors the shape of JwtBearerEvents — four async hooks for the moments that matter:

.AddPostQuantumJwtBearer(options =>
{
    options.ValidationParameters = new PqJwtValidationParameters { /* ... */ };

    // Substitute a token from a non-Authorization-header source.
    // SignalR's ?access_token= is the canonical use case.
    options.Events.OnMessageReceived = ctx =>
    {
        if (ctx.HttpContext.Request.Path.StartsWithSegments("/hub"))
        {
            var query = ctx.HttpContext.Request.Query["access_token"].ToString();
            if (!string.IsNullOrEmpty(query))
            {
                ctx.Token = query;
            }
        }

        return Task.CompletedTask;
    };

    // Enrich the principal after a token has been successfully validated.
    options.Events.OnTokenValidated = ctx =>
    {
        var identity = (System.Security.Claims.ClaimsIdentity)ctx.Principal.Identity!;
        identity.AddClaim(new("tenant", ResolveTenant(ctx.HttpContext)));
        return Task.CompletedTask;
    };

    // Observe (or, rarely, override) the failure outcome.
    options.Events.OnAuthenticationFailed = ctx =>
    {
        // ctx.Exception is the PqJwtValidationException.
        // Setting ctx.Result downgrades the default Fail() — usually you
        // just log and let the fail-closed default stand.
        return Task.CompletedTask;
    };

    // Customise the 401 challenge response.
    options.Events.OnChallenge = ctx =>
    {
        if (ctx.HttpContext.Request.Path.StartsWithSegments("/api"))
        {
            ctx.HttpContext.Response.Headers["X-PQ-Auth"] = "required";
        }

        // ctx.Handled = true; suppresses the default WWW-Authenticate header.
        return Task.CompletedTask;
    };
});

Hook delegates default to no-ops, so leaving Events alone gives you the same behaviour as not having the hooks at all.

A captured token shouldn't be reusable. For any deployment with more than one instance, configure a distributed replay cache. The companion package ships a Redis implementation that's a one-line wireup:

dotnet add package PostQuantum.AspNetCore.RedisReplayCache --version 1.0.0-preview.3
using PostQuantum.AspNetCore.RedisReplayCache;

builder.Services
    .AddAuthentication(PostQuantumJwtBearerDefaults.AuthenticationScheme)
    .AddPostQuantumJwtBearer(options =>
    {
        options.ValidationParameters = new PqJwtValidationParameters
        {
            SignatureVerificationKey = verificationKey,
            ValidIssuer   = "https://issuer.example",
            ValidAudience = "https://api.example",
        };
    });

// One line: registers RedisPqJwtReplayCache as a singleton, wires it
// onto the scheme's ValidationParameters.ReplayCache via PostConfigure.
builder.Services.AddPostQuantumJwtRedisReplayCache(
    connectionString: builder.Configuration["Redis:ConnectionString"]!);

Under the hood: every accepted token issues a Redis SET key 1 NX PX {remaining-token-lifetime}. First use wins, replays return false → validator throws PqJwtValidationException → handler returns 401. The TTL means the cache cleans itself up after token expiration.

Why this matters: without a configured replay cache, the jti claim is carried by every token but never enforced. A captured token is reusable until it expires. The library is opt-in on this because single-process apps don't need a distributed cache — but for anything multi-instance, this is the recommended production configuration.

The bundled InMemoryReplayCache from PostQuantum.Jwt works for single-process apps; the SECURITY-MODEL.md documents the deployment-shape matrix in detail.

OpenTelemetry: metrics and distributed tracing

The library emits Metrics + ActivitySource under the "PostQuantum.AspNetCore" instrumentation name. One-liner wireup:

using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;

builder.Services.AddOpenTelemetry()
    .WithMetrics(m => m.AddMeter("PostQuantum.AspNetCore")
                       .AddPrometheusExporter())
    .WithTracing(t => t.AddSource("PostQuantum.AspNetCore")
                       .AddAspNetCoreInstrumentation()
                       .AddOtlpExporter());

You get auth-success/failure counters, validation-latency histograms, key-ring lookup tags, and a per-validation tracing span — everything you need to build a "post-quantum auth health" dashboard.

Full signal contract in the recipes.

Issuing tokens (server-side)

Token minting lives in the engine library (PostQuantum.Jwt), because the issuer doesn't need ASP.NET Core to mint:

using PostQuantum.Jwt;

// signingKey is the private half — load from your secret store.
string token = new PqJwtBuilder()
    .WithIssuer("https://issuer.example")
    .WithAudience("https://api.example")
    .WithSubject("user-42")
    .WithJwtId(Guid.NewGuid().ToString("N"))      // for replay protection
    .WithLifetime(TimeSpan.FromMinutes(15))
    .WithClaim("role", "admin")
    .WithKeyId("signing-key-2026-q2")              // for kid rotation
    .SignWith(signingKey)
    .Build();

Publish the verification half (public key) via your JWKS-equivalent endpoint so resource servers can validate without sharing secrets.

Key rotation across services

Use AddPostQuantumJwtKeyRing(uri) to fetch verification keys from a trusted HTTPS endpoint (the post-quantum analogue of JWKS). The validator picks the right key for each incoming token from its kid header:

builder.Services
    .AddAuthentication(PostQuantumJwtBearerDefaults.AuthenticationScheme)
    .AddPostQuantumJwtBearer(options =>
    {
        options.ValidationParameters = new PqJwtValidationParameters
        {
            ValidIssuer   = builder.Configuration["Auth:Issuer"],
            ValidAudience = builder.Configuration["Auth:Audience"],
            // No key here — the ring supplies it.
        };
    });

// Registers HttpPostQuantumJwtKeyRing as a typed HTTP client and
// post-configures it onto the named options. No BuildServiceProvider()
// dance.
builder.Services.AddPostQuantumJwtKeyRing(
    new Uri(builder.Configuration["Auth:KeysEndpoint"]!));

For a non-HTTP key source (database, KMS, file), supply your own IPostQuantumJwtKeyRing implementation and register it generically:

builder.Services.AddPostQuantumJwtKeyRing<MyDatabaseKeyRing>();

Warm the cache at startup. A cold cache means the first authentication request pays a network round trip while every other request waits. Register the hosted-service warmup helper to preload at host start (and optionally on a periodic timer so removed keys drop out without waiting for an unknown-kid miss):

builder.Services.AddPostQuantumJwtKeyRing(
    new Uri(builder.Configuration["Auth:KeysEndpoint"]!));

builder.Services.AddPostQuantumJwtKeyRingWarmup(options =>
{
    options.FailFastOnStartup = true;                // default
    options.RefreshInterval   = TimeSpan.FromMinutes(15);
});

FailFastOnStartup (default true) makes a startup-time fetch failure abort the host — strict, but matches the engine library's fail-closed ethos. Set it to false for best-effort warmup that logs and lets the host come up; the first cache miss will then drive a refresh as usual.

The expected key-directory document is JSON:

{ "keys": [ { "kid": "2026-q2", "alg": "ML-DSA-65", "key": "<base64>" } ] }

Entries with any other alg are ignored — the single-suite policy holds across services.

Custom scheme name

If you already have a JwtBearer scheme on the same app (e.g. for a slow migration), register the post-quantum scheme under its own name and route specific endpoints to it:

builder.Services
    .AddAuthentication()
    .AddJwtBearer("Classical", o => { /* legacy config */ })
    .AddPostQuantumJwtBearer("PostQuantum", o =>
    {
        o.ValidationParameters = new PqJwtValidationParameters { /* ... */ };
    });
[Authorize(AuthenticationSchemes = "PostQuantum")]
public class ProtectedController : ControllerBase { /* ... */ }

Don't AddJwtBearer alongside this on the default scheme. The standard handler will try to parse the token's alg and fail. Either use AddPostQuantumJwtBearer as your only bearer auth, or restrict each scheme to specific routes with [Authorize(AuthenticationSchemes = ...)].


Public API at a glance

Type Purpose
PostQuantumJwtBearerExtensions AddPostQuantumJwtBearer(...) extension methods on AuthenticationBuilder.
PostQuantumJwtBearerHandler Fail-closed AuthenticationHandler that delegates to PqJwtValidator.
PostQuantumJwtBearerOptions Strongly-typed configuration: validation parameters, claim mapping, challenge details.
PostQuantumJwtBearerDefaults Scheme name and Bearer constant.
PostQuantumJwtBearerEvents OnMessageReceived / OnTokenValidated / OnAuthenticationFailed / OnChallenge async hooks.
IPostQuantumJwtKeyRing JWKS-equivalent abstraction for kid → MLDsa resolution (sync + async).
HttpPostQuantumJwtKeyRing HTTP-backed key ring with refresh, in-memory cache, atomic snapshot swap, AOT-safe JSON.
PostQuantumJwtKeyRingExtensions AddPostQuantumJwtKeyRing(...) DI helpers (HTTP and generic).
PostQuantumJwtKeyRingWarmupExtensions AddPostQuantumJwtKeyRingWarmup(...) — hosted-service preload + periodic refresh.
PostQuantumJwtKeyDirectory / …KeyEntry DTOs for the key-directory wire format.

Defaults and what they mean

Setting Default Why
Scheme name "PostQuantumJwtBearer" Distinct from the standard "Bearer" scheme so the two can coexist during migration.
NameClaimType "sub" Standard JWT subject claim. The default JwtBearer value ("unique_name") is less portable.
RoleClaimType "role" Matches common ML-DSA-issued tokens; works with [Authorize(Roles = ...)] out of the box.
IncludeErrorDetailsInChallenge true The 401 WWW-Authenticate header carries error="invalid_token". Set to false if you'd rather not signal why.
TimeProvider TimeProvider.System (inherited from AuthenticationSchemeOptions) Override with TimeProvider.Fake for deterministic tests.

Compared to Microsoft.AspNetCore.Authentication.JwtBearer

Microsoft.AspNetCore.Authentication.JwtBearer is the right choice for any JWT work that needs to interoperate with OAuth/OIDC, JWKS, the IANA JOSE algorithm registry, or any third-party token issuer. Use it unless you have a specific reason not to.

PostQuantum.AspNetCore is a focused, deliberately non-interoperable tool for one problem: hybrid post-quantum JWT authentication.

Concern Microsoft.AspNetCore.Authentication.JwtBearer PostQuantum.AspNetCore
Algorithms RS/PS/ES/EdDSA/HS — the full IANA catalogue. One suite only: ML-DSA-65 for signatures; X-Wing + AES-256-GCM for encryption.
Quantum resistance None of the standard algorithms are quantum-resistant. Hybrid: classical and post-quantum, both must fall.
Algorithm agility Yes — and historically the source of alg: none, RS/HS confusion, and downgrade attacks. No, by design. The validator does not trust the token's alg to pick a path; it accepts exactly one.
Standards interop Fully IANA-registered identifiers; tokens validate in every JWT library. Identifiers (ML-DSA-65, X-Wing) are not IANA-registered. Tokens will not validate in generic JWT tooling.
JWKS First-class. IPostQuantumJwtKeyRing + HTTP-backed implementation — JWKS-equivalent over a deliberately trivial wire format.
External audit Yes — widely deployed and reviewed. No. Preview, not audited.
Dependencies Microsoft.IdentityModel.* family. PostQuantum.Jwt + the Microsoft.AspNetCore.App framework reference.
Target framework net8 / net9 / net10. net10.0 only (matches the engine).

Use Microsoft.AspNetCore.Authentication.JwtBearer if you need OAuth/OIDC interop, JWKS, multi-algorithm agility, or any standards-conformant JWT.

Use PostQuantum.AspNetCore if you specifically want hybrid post-quantum tokens now, you control both the issuer and the verifier, and you accept that your tokens won't validate in any other ecosystem until IANA registers these identifiers and standard libraries catch up.

Not to be confused with…

Package What it is Why it isn't this
BouncyCastle.Cryptography A full-stack C# cryptography toolkit — block ciphers, public-key crypto, X.509, TLS, PKCS, OpenPGP, post-quantum primitives, and more. A primitive library — no JWT support, no ASP.NET Core integration. PostQuantum.Jwt uses it for X25519 only; this package never touches it directly.
liboqs / liboqs-dotnet Open-source post-quantum cryptography primitives (KEMs, signatures) maintained by the Open Quantum Safe project. A primitive library. Different choice from the BCL's MLDsa/MLKem; the engine library has chosen the BCL path.
System.Security.Cryptography (BCL) The .NET 10 base class library — including FIPS-validated MLDsa, MLKem, AesGcm, etc. The actual implementation under everything else in the diagram above. PostQuantum.AspNetCore does not reimplement any BCL primitive.
PostQuantum.Jwt The engine: PqJwtBuilder to mint hybrid signed / signed-then-encrypted tokens; PqJwtValidator to verify them; the X-Wing combiner; the JWE wire format. The library under PostQuantum.AspNetCore. If you're not using ASP.NET Core, use this directly.
Microsoft.AspNetCore.Authentication.JwtBearer Microsoft's standard JWT bearer handler. Supports every IANA JOSE algorithm. The right choice for every JWT scenario except post-quantum. This package is the post-quantum sibling, not a replacement.

Migrating from AddJwtBearer

Moving from Microsoft.AspNetCore.Authentication.JwtBearer is deliberately a one-line change at the call site. The validation model is different (we use a static key or a custom JWKS-equivalent ring, not an OIDC Authority), but the shape is identical: same AuthenticationBuilder, same scheme name pattern, same [Authorize] attribute, same ClaimsPrincipal downstream. Most controllers, policies, and middleware need no change at all.

Side-by-side

Before — classical JwtBearer against an OIDC provider:

using Microsoft.AspNetCore.Authentication.JwtBearer;

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        // JwtBearer discovers signing keys from the OIDC metadata document.
        options.Authority = "https://auth.example/";
        options.Audience  = "https://api.example/";
        options.TokenValidationParameters.ValidIssuer = "https://auth.example/";
    });

builder.Services.AddAuthorization();

After — post-quantum AddPostQuantumJwtBearer against your own issuer:

using PostQuantum.AspNetCore;
using PostQuantum.Jwt;

builder.Services
    .AddAuthentication(PostQuantumJwtBearerDefaults.AuthenticationScheme)
    .AddPostQuantumJwtBearer(options =>
    {
        // No OIDC discovery — supply the ML-DSA-65 verification key directly
        // (or via the JWKS-equivalent IPostQuantumJwtKeyRing for rotation).
        options.ValidationParameters = new PqJwtValidationParameters
        {
            SignatureVerificationKey = verificationKey,
            ValidIssuer   = "https://issuer.example",
            ValidAudience = "https://api.example",
        };
    });

builder.Services.AddAuthorization();

Everything downstream of those lines — [Authorize], [Authorize(Roles = "...")], policies, User.FindFirst("sub"), HttpContext.User.IsAuthenticated — works unchanged.

What's different

Concern AddJwtBearer AddPostQuantumJwtBearer
Algorithms accepted Full IANA catalogue (RS/PS/ES/EdDSA/HS). Exactly one suite: ML-DSA-65.
Key source Authority (OIDC discovery) or IssuerSigningKey. SignatureVerificationKey (static) or IPostQuantumJwtKeyRing (dynamic).
Identity provider integration Auth0, IdentityServer, Microsoft Entra, etc. You issue tokens via PqJwtBuilder. Not OIDC-compatible.
Token size ~200 bytes (HMAC) → ~1 KB (RSA). ~4.5 KB (ML-DSA-65 signature is 3,309 bytes).
Algorithm agility Yes (and historically a source of CVEs). No, by design. Token's alg doesn't pick a code path.
Replay protection Not built-in. Built-in via IPqJwtReplayCache + Redis companion (opt-in).
Standards interop Tokens validate in any JWT library. Tokens are non-interoperable until IANA registers ML-DSA-65.
Production maturity Yes — decade-hardened. Preview — not audited, not for production.

Run both during migration

You don't have to flip a switch — register both schemes and route specific endpoints to each:

builder.Services
    .AddAuthentication()
    .AddJwtBearer("Classical", o => { o.Authority = "https://auth.example/"; })
    .AddPostQuantumJwtBearer("PostQuantum", o =>
    {
        o.ValidationParameters = new PqJwtValidationParameters { /* ... */ };
    });
[Authorize(AuthenticationSchemes = "PostQuantum")]
public class PostQuantumOnlyController : ControllerBase { }

[Authorize(AuthenticationSchemes = "Classical,PostQuantum")]
public class EitherWorksController : ControllerBase { }

See docs/RECIPES.md § 7 for the full coexistence pattern.


Migrating from PostQuantum.Jwt.AspNetCore

If you're on the legacy PostQuantum.Jwt.AspNetCore companion package (which shipped from the engine repository), PostQuantum.AspNetCore is its renamed, repackaged successor. Same engine, cleaner naming, its own release cadence. The mapping is mechanical (AddPqJwtBearerAddPostQuantumJwtBearer, PqJwtBearer*PostQuantumJwtBearer*, IPqJwtKeyRingIPostQuantumJwtKeyRing), and tokens minted by either package validate in the other.

See docs/MIGRATION.md for the diff-style guide.


Security posture

The short version, honestly. The full security contract — what the library protects, what it does NOT protect, the replay-protection deployment matrix, the key-rotation cadence, and the fail-closed contract enumerated as a logical conjunction — lives in docs/SECURITY-MODEL.md. Read that before depending on this for anything that matters.

What you get

  • Fail-closed validation. Bad signature, tampered ciphertext, expired or not-yet-valid token, wrong issuer/audience, missing exp, missing alg, or an alg we don't expect — every one of those throws inside PqJwtValidator, and the handler turns it into AuthenticateResult.Fail. There is no alg: none, no unsigned path, no silent downgrade, and no exception class from Validate() escapes as a 500.
  • Native post-quantum primitives. ML-DSA-65 and ML-KEM-768 are the FIPS-validated .NET 10 BCL implementations, not a re-implementation.
  • Hybrid by construction (for encrypted tokens). Confidentiality stays secure unless both X25519 and ML-KEM-768 fall.
  • Strict, small-surface defaults. Expiration is required, clock skew is a modest 60 seconds, only the exact post-quantum algorithms are accepted, the bearer prefix is matched case-insensitively per RFC 6750, and the WWW-Authenticate realm is RFC 7235 quoted-string escaped.

What you must know

  • Not audited. No third party has reviewed the design or implementation.
  • Non-standard identifiers. alg/enc values (ML-DSA-65, X-Wing) are not IANA-registered. Tokens are intentionally not interoperable with generic JWT tooling.
  • Preview. Treat the API and wire format as unstable until 1.0.
  • ⚠️ Replay defence is opt-in. Without a configured IPqJwtReplayCache, captured tokens are reusable until they expire. For multi-instance production, use PostQuantum.AspNetCore.RedisReplayCache — see the headline section above.

Full detail in docs/SECURITY-MODEL.md, SECURITY.md, and KNOWN-GAPS.md.


Compatibility

Surface Supported
Target framework net10.0
ASP.NET Core 10.x via <FrameworkReference Include="Microsoft.AspNetCore.App" />
Languages C# 13
Operating system Windows, Linux, macOS — wherever .NET 10 runs with an ML-KEM / ML-DSA-capable OpenSSL. On Linux that means OpenSSL 3.5 or later.
AOT / trimming IsAotCompatible=true, IsTrimmable=true. The HTTP key-ring JSON path is source-generated.

Building from source

dotnet build         # zero warnings (compiler warnings are errors)
dotnet test          # 31 tests, zero skips on PQ-capable hosts
dotnet format        # apply the .editorconfig style
dotnet run --project samples/PostQuantum.AspNetCore.Demo

The test suite is Microsoft.AspNetCore.Mvc.Testing-backed and exercises the fail-closed contract end-to-end: valid token → 200 OK with the expected ClaimsPrincipal, every tampered/expired/wrong-issuer/wrong-audience case → 401 Unauthorized, plus assertions on each of the three event hooks. Tests that exercise the native ML-DSA primitives skip themselves with a stated reason (PqcFactAttribute) on hosts where the BCL primitives aren't available; both CI lanes (Windows native + Linux with OpenSSL 3.5+ via conda-forge) fail the run if any test reports skipped.


Contributing

Issues and pull requests are welcome. Before opening a PR:

  1. Run dotnet build and dotnet test — both must be green, with zero warnings (the build treats compiler warnings as errors).
  2. Keep the discipline in CLAUDE.md: honesty over polish, fail-closed always, no rolled-your-own crypto, native BCL first.
  3. Security-sensitive changes should land alongside a test that locks in the fail-closed behaviour.

Reporting a vulnerability: please do not open a public issue. Use GitHub's Report a vulnerability button on the repository, or follow the process in SECURITY.md.


License

MIT.


About this library

This library was created by a human developer working in close collaboration with Claude, Gemini, Grok, and ChatGPT. The vision, direction, architecture decisions, and final curation were mine. The goal was simple: build something the .NET ecosystem genuinely needs for the post-quantum era.


To God be the glory — 1 Corinthians 10:31.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on PostQuantum.AspNetCore:

Package Downloads
PostQuantum.AspNetCore.RedisReplayCache

A Redis-backed implementation of PostQuantum.Jwt's IPqJwtReplayCache, intended for horizontally-scaled deployments of PostQuantum.AspNetCore. Uses StackExchange.Redis SET NX with TTL = remaining token lifetime, so single-use jti enforcement coordinates across all instances. Companion to PostQuantum.AspNetCore. Preview release — not for production use.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0-preview.3 37 6/2/2026
1.0.0-preview.2 56 5/31/2026
1.0.0-preview.1 53 5/31/2026

1.0.0-preview.3: Suite-reconciliation + integration-hardening release. (1) Suite version reconciliation — PostQuantum.Jwt engine dependency moves to 1.0.0-preview.1 to align with the wider PostQuantum.* ecosystem; no source-level API change vs 1.0.0-preview.2. (2) Trust-root documentation — SECURITY.md gains an explicit "Trust root: the HTTP key directory" section telling operators that the HTTPS key-directory endpoint is the root of trust for token validation and SHOULD be protected with certificate pinning or a hardened HttpClient. (3) AddPostQuantumJwtKeyRing(...) gains an optional configureHttpClient hook so wiring a pinned HttpMessageHandler is the obvious path, not a fork. (4) Bounded key-directory response — the DI helper now caps the typed HttpClient at MaxResponseContentBufferSize=1MB; manual constructors get an optional maxResponseBytes parameter (default unchanged). (5) Unknown-kid flood mitigation — Resolve now short-circuits via a bounded short-TTL negative cache before the sync-over-async bridge; a kid which becomes valid is wrongly rejected for at most 10 seconds, matching the pre-existing forced-refresh throttle (no regression to that bound). No change to fail-closed behaviour, no change to the single-suite policy. Full suite green. See KNOWN-GAPS.md and VERSION-RECONCILIATION.md. Full notes: https://github.com/systemslibrarian/postquantum-aspnetcore/blob/main/CHANGELOG.md.