As of this month, every session minted by UniAuth carries a post-quantum digital signature. The algorithm is ML-DSA-44 (formerly CRYSTALS-Dilithium), standardized in FIPS 204 by NIST in August 2024. This post explains why we moved early, how the implementation works inside UniAuth, and what you need to know as an operator.
Why Post-Quantum Matters for Sessions
The usual argument for post-quantum cryptography centers on harvest-now-decrypt-later attacks against long-lived encrypted data. Sessions, by contrast, are short-lived. So why bother?
The answer is infrastructure lead time. Migrating signature algorithms in a production identity provider is not a flag flip. It requires key generation infrastructure, a new signature format, a verification path in every session-touching code path, and a migration strategy for the millions of sessions already in flight. If we wait until quantum computers are imminent, we are already years too late to migrate safely.
Beyond that, session signatures protect the integrity binding between a session row in the database and the user it belongs to. If an attacker can forge a signature, they can graft their session onto another user's account. Classical DSA or ECDSA signatures are not at risk today, but by starting now, we eliminate the question entirely for the foreseeable future.
ML-DSA-44: The Algorithm
ML-DSA (Module-Lattice-Based Digital Signature Algorithm) is a lattice-based scheme. The "44" denotes NIST security level 2, roughly equivalent to 128 bits of classical security. We chose level 2 rather than level 5 because the signatures protect ephemeral session data, not decades-long secrets, and the performance difference matters at scale.
Key characteristics of ML-DSA-44:
- Public key size: 1,312 bytes
- Secret key size: 2,560 bytes
- Signature size: 2,420 bytes
- Sign time: ~0.3ms on modern server hardware
- Verify time: ~0.2ms on modern server hardware
The signature size is the main trade-off. A classical Ed25519 signature is 64 bytes; an ML-DSA-44 signature is 2,420 bytes. Since we store signatures in the sessions.pqc_signature column (base64-encoded, so roughly 3.2 KB), this adds a small but measurable increase in database row size. At our current session volume, the storage cost is negligible.
Performance: Staying Under 2ms
Session touch is the hottest path in UniAuth. Every authenticated request calls touchSession(), which reads the session row, checks inactivity and absolute timeouts, verifies the fingerprint, and validates the PQC signature. Adding ML-DSA-44 verification to this path had to stay under our 2ms overhead budget.
Our implementation uses @noble/post-quantum/ml-dsa.js, a pure-JavaScript implementation by Paul Miller. On a single core of an AMD EPYC 7763, verification averages 0.18ms. Combined with the database round-trip and other checks, the total touchSession call stays well under 2ms of added latency.
// From lib/crypto.ts — the v2 sign function
export function signSessionV2(sessionId: string, userId: string): string | null {
const sig = signSession(v2Plaintext(sessionId, userId))
return sig ? `${SESSION_SIG_V2}${sig}` : null
}
function v2Plaintext(sessionId: string, userId: string): string {
return `v2:${sessionId}:${userId}`
}
The v2 Signature Format
Our initial implementation (v1) signed ${sessionId}:${userId}:${createdAt}, where createdAt was the JavaScript timestamp at sign time. The problem: the database stores created_at using NOW(), and the two clocks drifted by microseconds. The signed data never matched the stored data, so v1 signatures would never verify. We caught this in testing before it reached production, but it taught us a lesson about only signing stable fields.
The v2 format signs exactly two fields: sessionId and userId. Both are UUIDs assigned before the signing call and immutable after insertion. The signed plaintext is prefixed with v2: to disambiguate from legacy:
Plaintext: "v2:{sessionId}:{userId}"
Stored: "v2:{base64-encoded ML-DSA-44 signature}"
The v2: prefix on the stored value lets verifySessionV2() distinguish between three cases:
- Null or empty — legacy session with no signature. Accepted (fail-open for availability during migration).
- Present but no
v2:prefix — v1 legacy signature. Accepted without verification (known to be unverifiable). v2:prefix — verified against the session's (sessionId, userId) pair. Failure terminates the session and logs asession_pqc_mismatchactivity event.
How Verification Works in touchSession
The touchSession() function in lib/session-utils.ts is the central session-validation choke point. Every authenticated request passes through it. After checking inactivity timeout and absolute session lifetime, it reaches the PQC verification block:
// From lib/session-utils.ts
if (session.pqc_signature && !verifySessionV2(
session.id, session.user_id, session.pqc_signature
)) {
await pool.query(
"UPDATE sessions SET is_active = FALSE WHERE id = $1",
[session.id]
)
return false
}
The key subtlety is the isPQCReady() check inside verifySessionV2. During server startup, the ML-DSA-44 keypair is loaded asynchronously from the system_settings table by the instrumentation hook. There is a brief window (typically under 100ms) where the keypair has not loaded yet. During this window, verifySessionV2 returns true for v2 signatures rather than rejecting them. This is a deliberate fail-open for availability: we would rather serve a request during startup than force a cold-boot cascade of session invalidations.
Key Management
The ML-DSA-44 keypair is generated once at first startup and stored in the system_settings table. The secret key is encrypted at rest with AES-256-GCM using the ENCRYPTION_KEY environment variable. The public key is stored in plaintext because it is, well, public.
-- Stored in system_settings
mldsa_public_key = base64(publicKey) -- 1,312 bytes
mldsa_secret_key_enc = AES-256-GCM(base64(secretKey)) -- 2,560 bytes, encrypted
If you rotate ENCRYPTION_KEY, you must re-encrypt the secret key. The initPQCKeys() function handles this transparently on startup: if it cannot decrypt the stored key, it logs an error and falls back to unsigned sessions (no verification enforcement) until the operator resolves the key mismatch.
Legacy Migration Strategy
When a deployment ships this change with pre-existing active sessions, invalidating them all simultaneously would log the whole user base out in one go. The migration strategy we use (and recommend) is:
- Deploy the new code. New sessions get v2 signatures. Existing sessions have null or v1 signatures.
- Wait one session lifetime (30 days). All sessions without v2 signatures naturally expire via the absolute lifetime check.
- After 30 days, all surviving sessions are v2-signed. At this point, you could optionally tighten
verifySessionV2to reject null/v1 signatures, but we have not done so because the cost is zero (they expire anyway) and the benefit is marginal.
This is the same pattern we use for any session-level change: deploy, wait, converge. No mass invalidation, no user disruption.
What This Means for You
If you run UniAuth, you do not need to change anything. The PQC signature is opt-in by default (it activates automatically when the keypair initializes) and backward-compatible with all existing sessions. The only observable difference is a slightly larger pqc_signature column in your sessions table.
If you are building on UniAuth's OAuth/OIDC endpoints, the PQC signature is invisible to you. It protects the internal session; your OAuth tokens (JWTs signed with HS256/RS256) are unchanged. When we migrate token signatures to post-quantum algorithms, that will be a separate announcement with its own migration path.
We believe identity providers should lead on cryptographic migrations, not follow. By the time post-quantum is urgent, we want it to be boring infrastructure that has been running in production for years.