Quantum-Safe Cloud -- Part 4
QuantumID: Authentication Built for What's Coming
The Problem
JWT is everywhere. If you’ve built an API in the last ten years, you’ve almost certainly used it. It’s simple, stateless, and well-supported.
But JWT has problems that most developers don’t think about until something breaks.
Problem 1: No revocation. A JWT is valid until it expires. If a user’s token is stolen — from a browser, from a log file, from a compromised device — the attacker can use it until the expiry time. There’s no standard way to invalidate a specific token without a token blocklist (which defeats the statelessness that made JWT attractive in the first place).
Problem 2: Algorithm confusion. The JWT header includes the alg field, and some JWT libraries trust it. If your library accepts alg: "none", an attacker can strip the signature. If it accepts both RS256 and HS256, an attacker can sign with the public key as an HMAC secret. These vulnerabilities have caused real incidents in production systems.
Problem 3: The quantum problem. RS256 (the most common JWT signing algorithm) uses RSA. RS256 tokens signed today will be forgeable by a quantum computer in the future. An attacker who collects signed tokens now can verify them against forged-but-valid tokens later, or forge new ones once RSA breaks.
The solution isn’t to patch JWT. It’s to use a proper identity system where these decisions are made correctly by design, and where the cryptography is future-proof.
QuantumID is an OIDC/OAuth2 identity provider built on those foundations. Tokens are signed with ML-DSA (quantum-safe). Sessions are managed server-side (revocation works). The algorithm is not configurable by the client (no algorithm confusion). And because it’s OIDC, integrating it into a .NET app is standard — AddOpenIdConnect, a few options, done.
The Solution
OIDC (OpenID Connect) is an identity layer on top of OAuth2. Your application delegates authentication to an identity provider (QuantumID) and receives back a JWT ID token. The difference from DIY JWT:
- The token is issued by a trusted authority (QuantumID), not by your application
- The signature uses ML-DSA — quantum-safe and not configurable by the caller
- Sessions exist server-side — revocation works
- Scopes control what the token gives access to
- The PKCE flow prevents authorization code interception
In practice, for a .NET API, this means:
- Remove your custom JWT issuance code
- Add
AddOpenIdConnectpointing to QuantumID - Your API validates tokens using QuantumID’s public keys (via JWKS endpoint)
- Login, logout, and token refresh are handled by QuantumID
Your application stops being an identity provider. It becomes a relying party. This is the right division of responsibility.
Execute
We’ll replace the custom JWT implementation in the users API with QuantumID.
ATLAS for this task
[A] ARCHITECT
Replace DIY JWT with QuantumID as the OIDC provider.
The API becomes a resource server (validates tokens, doesn't issue them).
Authentication: PKCE flow, QuantumID as authority.
Out of scope: social login (Google/GitHub), enterprise SSO (SAML) — future.
[T] TRACE
User calls POST /auth/login → redirect to QuantumID login page
User authenticates at QuantumID → QuantumID issues code
Code → POST /connect/token → QuantumID returns access_token + id_token
Client calls API with Bearer token → API validates against QuantumID JWKS
Token expires → client uses refresh_token → QuantumID issues new access_token
[L] LINK
API → QuantumID: HTTPS, JWKS validation, token introspection
Client → QuantumID: PKCE authorization flow
QuantumID → PostgreSQL (QuantumAPI internal): session storage
[A] ASSEMBLE
1. Register application in QuantumID dashboard
2. Remove JwtService from users-api
3. Add AddAuthentication + AddJwtBearer pointing to QuantumID
4. Configure JWKS URI and token validation parameters
5. Update endpoints: remove /auth/login and /auth/refresh
6. Update UsersController: read sub claim for user identity
[S] STRESS-TEST
JWKS endpoint unreachable?
→ .NET caches JWKS keys — short-term outage handled
Token expired?
→ 401 → client uses refresh_token
Token revoked?
→ Introspection call returns inactive → 401
Step 1: Register your application in QuantumID
Go to quantumapi.eu, navigate to QuantumID → Applications, and create a new application:
- Type: Web Application
- Redirect URIs:
https://your-api.example.com/signin-oidc - Post-logout redirect URIs:
https://your-api.example.com/signout-callback-oidc - Allowed scopes:
openid profile email - Grant types: Authorization Code + PKCE
You’ll receive a client_id and client_secret. Store the secret in QuantumVault (article 2).
Step 2: Remove the DIY JWT code
Delete JwtService.cs and the /auth/login and /auth/refresh endpoints from the users API. They’re no longer needed — QuantumID handles all of this.
Step 3: Configure authentication in Program.cs
// Program.cs
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://id.quantumapi.eu"; // QuantumID authority
options.Audience = "your-client-id"; // From QuantumID dashboard
options.RequireHttpsMetadata = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30)
// Algorithm validation: QuantumID enforces ML-DSA — no alg confusion possible
};
});
builder.Services.AddAuthorization();
That’s it. .NET’s AddJwtBearer automatically:
- Fetches the JWKS from
https://id.quantumapi.eu/.well-known/jwks.json - Caches the signing keys
- Validates incoming tokens against those keys
- Refreshes the JWKS cache when unknown key IDs appear
Step 4: Read the user identity from claims
Before, JwtService put a custom userId claim in the token. Now, QuantumID puts the user’s ID in the standard sub claim:
// UsersController.cs
[HttpGet("{id:guid}")]
[Authorize]
public async Task<IActionResult> GetUser(Guid id)
{
var callerId = User.FindFirstValue(ClaimTypes.NameIdentifier); // = "sub" claim
if (callerId is null) return Unauthorized();
// Ownership check: a user can only read their own record
if (callerId != id.ToString()) return Forbid();
var user = await _users.GetByIdAsync(id);
return user is null ? NotFound() : Ok(user);
}
Step 5: Token introspection for revocation
For most APIs, JWT validation is enough: validate the signature and expiry, done. But if you need to honour revocation (a user logs out and their token becomes invalid immediately), you need token introspection.
// Add introspection as a fallback validator
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://id.quantumapi.eu";
options.Audience = "your-client-id";
options.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
// Only introspect if the token has < 5 minutes left
// to avoid introspecting every request
var expiry = context.SecurityToken.ValidTo;
if (expiry - DateTimeOffset.UtcNow > TimeSpan.FromMinutes(5))
return;
var introspection = context.HttpContext
.RequestServices.GetRequiredService<ITokenIntrospectionService>();
var token = context.Request.Headers.Authorization
.ToString().Replace("Bearer ", "");
var active = await introspection.IsActiveAsync(token);
if (!active)
context.Fail("Token has been revoked");
}
};
});
// TokenIntrospectionService.cs
public class TokenIntrospectionService : ITokenIntrospectionService
{
private readonly HttpClient _http;
public TokenIntrospectionService(HttpClient http)
{
_http = http;
}
public async Task<bool> IsActiveAsync(string token)
{
var response = await _http.PostAsync(
"https://id.quantumapi.eu/connect/introspect",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["token"] = token,
["client_id"] = "your-client-id",
["client_secret"] = Environment.GetEnvironmentVariable("OIDC_CLIENT_SECRET")!
}));
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("active").GetBoolean();
}
}
For most APIs, you don’t need introspection on every request — only for near-expiry tokens, or for high-sensitivity operations (payment, account deletion). Standard JWT validation is fine for normal reads.
Step 6: The PKCE flow for browser clients
If you have a React frontend (like the tenant portal from the blog project), use the PKCE flow:
// auth.ts
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
export const userManager = new UserManager({
authority: 'https://id.quantumapi.eu',
client_id: 'your-client-id',
redirect_uri: `${window.location.origin}/signin-oidc`,
scope: 'openid profile email',
response_type: 'code',
// PKCE is automatic in oidc-client-ts
userStore: new WebStorageStateStore({ store: window.localStorage }),
});
export const login = () => userManager.signinRedirect();
export const handleCallback = () => userManager.signinRedirectCallback();
export const getAccessToken = async (): Promise<string | null> => {
const user = await userManager.getUser();
if (!user || user.expired) return null;
return user.access_token;
};
The token returned by QuantumID is signed with ML-DSA-65. Your API validates it against the JWKS. The signature algorithm is not in the token header as a client-controlled field — it’s determined by the QuantumID server configuration.
What we adjusted
Two adjustments after generating from the GOTCHA prompt:
1. JWKS caching. The AI configured AddJwtBearer without a BackchannelHttpHandler and without setting RefreshOnIssuerKeyNotFound. The default configuration re-fetches JWKS on every unknown key ID. We added explicit cache configuration to avoid hammering the QuantumID endpoint during key rotation.
options.BackchannelHttpHandler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(15)
};
options.RefreshOnIssuerKeyNotFound = true; // auto-refresh JWKS on unknown kid
2. The sub claim format. QuantumID uses UUID format for the sub claim (3fa85f64-5717-4562-b3fc-2c963f66afa6). The original users API used Guid.NewGuid().ToString() for user IDs. Make sure user IDs in your PostgreSQL database match the format of the sub claim from QuantumID — they should be the same UUIDs if you registered users via QuantumID.
Template
=== OIDC MIGRATION CHECKLIST ===
PRE-MIGRATION
[ ] Document all custom JWT claims your application uses today
(userId, role, tenantId, etc.)
[ ] Map each custom claim to its OIDC equivalent or plan a custom claim in the provider
QUANTUMID SETUP
[ ] Register application in QuantumID dashboard
[ ] Save client_id and client_secret (client_secret → QuantumVault)
[ ] Configure redirect URIs for all environments (dev, staging, prod)
[ ] Enable PKCE (should be default)
.NET CONFIGURATION
[ ] Replace AddAuthentication + AddJwtBearer with QuantumID authority
[ ] Remove JwtService and custom token generation
[ ] Update claim reading: User.FindFirstValue(ClaimTypes.NameIdentifier) for user ID
[ ] Test token validation with an actual token from QuantumID
ENDPOINTS
[ ] Remove POST /auth/login (handled by QuantumID)
[ ] Remove POST /auth/refresh (handled by QuantumID)
[ ] Keep POST /auth/logout if you need to trigger QuantumID end_session endpoint
INTROSPECTION (optional)
[ ] Decide: do you need immediate revocation?
- Low-sensitivity API: no. Short token expiry (15 min) is enough.
- High-sensitivity operations: yes. Add introspection for those endpoints.
FRONTEND
[ ] Integrate oidc-client-ts or MSAL (if Entra ID is your QuantumID upstream)
[ ] Handle token renewal (silent refresh before expiry)
[ ] Handle 401 responses: trigger re-authentication
Challenge
You now have:
- Secrets in QuantumVault (article 2)
- PII encrypted in the database (article 3)
- Authentication via QuantumID with ML-DSA tokens (this article)
The system is secure at rest and at the identity layer. But there’s still one major gap: the CI/CD pipeline.
Right now, the Azure DevOps pipeline from the ATLAS+GOTCHA series stores secrets in variable groups. That’s better than YAML, but it’s still RSA-encrypted inside Azure Key Vault, and it’s not managed by QuantumVault.
Article 5 fixes the pipeline: secrets from QuantumVault, image signing with Cosign + ML-DSA keys, and a supply chain security layer that goes further than Trivy scanning alone.
If this series helps you, consider buying me a coffee.
Loading comments...