DEV Community

Cover image for How to migrate from JWT to post-quantum tokens without breaking your API
German
German

Posted on

How to migrate from JWT to post-quantum tokens without breaking your API

You've read about ML-DSA-65. You understand why RS256 and ES256 are on borrowed time. Now the question is practical: how do you actually migrate a production API without taking everything down or breaking clients that already have tokens in the wild?

This is a step-by-step guide. By the end you'll have a working migration — login, logout, protected routes, and a transition strategy that lets old JWT tokens and new post-quantum tokens coexist during the rollout.


What we're starting from

A typical Express API with JWT looks like this:

import jwt from 'jsonwebtoken'
import express from 'express'

const app = express()
app.use(express.json())

// Login — sign a JWT
app.post('/login', async (req, res) => {
  const user = await db.users.findByEmail(req.body.email)
  if (!user || !checkPassword(req.body.password, user.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' })
  }

  const token = jwt.sign(
    { sub: user.id, email: user.email, role: user.role },
    process.env.JWT_SECRET,
    { algorithm: 'RS256', expiresIn: '1h' }
  )

  res.json({ token })
})

// Middleware — verify JWT on every protected route
function requireAuth(req, res, next) {
  const header = req.headers['authorization'] ?? ''
  if (!header.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Unauthorized' })
  }
  try {
    req.user = jwt.verify(header.slice(7), process.env.JWT_PUBLIC_KEY)
    next()
  } catch {
    res.status(401).json({ error: 'Invalid token' })
  }
}

app.get('/api/profile', requireAuth, (req, res) => {
  res.json({ user: req.user })
})
Enter fullscreen mode Exit fullscreen mode

This works. But the signature is RS256 — vulnerable to Shor's algorithm. The migration replaces that signature scheme with ML-DSA-65, which has no known quantum attack.


The transition strategy

The hardest part of any cryptographic migration isn't the new code — it's the existing tokens in the wild. Users that logged in yesterday have a JWT in their browser or mobile app. You can't invalidate all of them at once without forcing everyone to log out.

The approach that works:

  1. Deploy the new signing scheme — new logins get ML-DSA-65 tokens
  2. Keep the old verification — during the transition period, verify both JWT and ML-DSA-65 tokens
  3. Set a deprecation date — after N days (typically the max token lifetime), all old JWTs will have expired naturally
  4. Remove the old path — once the deprecation date passes, drop JWT verification entirely

This means zero forced logouts and zero downtime. Clients migrate automatically as their old tokens expire.


Step 1 — Install the SDK

npm install fipsign-sdk
Enter fullscreen mode Exit fullscreen mode

Get a free API key at app.fipsign.dev — no credit card required. Create a project, create an API key inside that project. Store it as an environment variable.

FIPSIGN_API_KEY=pqa_your_api_key
Enter fullscreen mode Exit fullscreen mode

Step 2 — Replace login

The new login signs the same payload with ML-DSA-65 instead of RS256. The token object returned by sign() is a JSON object — encode it to base64 before sending it to the client so it fits cleanly in an Authorization header.

import { PQAuth } from 'fipsign-sdk'

const pq = new PQAuth(process.env.FIPSIGN_API_KEY)

app.post('/login', async (req, res) => {
  const user = await db.users.findByEmail(req.body.email)
  if (!user || !checkPassword(req.body.password, user.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' })
  }

  const { token } = await pq.sign({
    sub:              user.id,
    email:            user.email,
    role:             user.role,
    expiresInSeconds: 3600,
  })

  // Encode to base64 — this is what the client sends in Authorization: Bearer <token>
  // (JSON objects with special characters can break header parsing)
  const encoded = Buffer.from(JSON.stringify(token)).toString('base64')
  res.json({ token: encoded })
})
Enter fullscreen mode Exit fullscreen mode

The payload structure is identical to what you had in JWT — sub, email, role. Clients don't need to change anything about how they store or send the token.


Step 3 — Add logout with revocation

This is where ML-DSA-65 tokens improve on JWT. With JWT, you can't revoke a token — once issued, it's valid until expiry. With FIPSign, revoke() immediately invalidates the token. Any future verify() call rejects it even if the signature is valid and the token hasn't expired.

app.post('/logout', async (req, res) => {
  const header = req.headers['authorization'] ?? ''
  if (header.startsWith('Bearer ')) {
    try {
      const token = JSON.parse(Buffer.from(header.slice(7), 'base64').toString('utf8'))
      await pq.revoke(token, 'user logged out')
    } catch { /* ignore malformed token */ }
  }
  res.json({ success: true })
})
Enter fullscreen mode Exit fullscreen mode

Step 4 — Replace the middleware (with transition support)

During the transition period, the middleware needs to handle both token formats. The simplest way is to try ML-DSA-65 first, and fall back to JWT verification for old tokens.

import { PQAuthError } from 'fipsign-sdk'

async function requireAuth(req, res, next) {
  const header = req.headers['authorization'] ?? ''
  if (!header.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Unauthorized' })
  }

  const raw = header.slice(7)

  // Try ML-DSA-65 first
  try {
    const token = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'))
    const result = await pq.verify(token)
    if (result.valid) {
      req.user = result.payload
      return next()
    }
    return res.status(401).json({ error: result.error ?? 'Invalid token' })
  } catch {
    // Not a base64 JSON token — try JWT fallback
  }

  // JWT fallback — remove this block after your deprecation date
  try {
    req.user = jwt.verify(raw, process.env.JWT_PUBLIC_KEY)
    return next()
  } catch {
    return res.status(401).json({ error: 'Invalid token' })
  }
}

app.get('/api/profile', requireAuth, (req, res) => {
  res.json({ user: req.user })
})
Enter fullscreen mode Exit fullscreen mode

Once your JWT deprecation date passes and all old tokens have expired, remove the JWT fallback block. The middleware becomes purely ML-DSA-65.


Step 5 — Use the built-in middleware (optional)

If you don't need the transition period, FIPSign ships an Express middleware that handles everything automatically:

// Protect all routes under /api
app.use('/api', pq.middleware())

// req.user is the verified payload — sub, email, role, exp, iat
app.get('/api/profile', (req, res) => {
  res.json({ user: req.user })
})
Enter fullscreen mode Exit fullscreen mode

The middleware reads Authorization: Bearer <base64(token)>, verifies it, and attaches the decoded payload to req.user. Returns 401 automatically on invalid tokens.


Python — FastAPI example

If you're running a Python backend, the migration looks the same. Install the SDK:

pip install fipsign-sdk
Enter fullscreen mode Exit fullscreen mode
from fastapi import FastAPI, Depends
from fipsign import PQAuth, fastapi_middleware

app          = FastAPI()
pq           = PQAuth("pqa_your_api_key")
require_auth = fastapi_middleware(pq)

@app.post("/login")
async def login(body: LoginRequest):
    user = await db.find_by_email(body.email)
    if not user or not check_password(body.password, user.password_hash):
        raise HTTPException(401, "Invalid credentials")

    result  = pq.sign(user.id, email=user.email, role=user.role, expires_in_seconds=3600)
    encoded = base64.b64encode(json.dumps(result.token.__dict__).encode()).decode()
    return {"token": encoded}

@app.post("/logout")
async def logout(request: Request):
    header = request.headers.get("Authorization", "")
    if header.startswith("Bearer "):
        try:
            token = PQToken(**json.loads(base64.b64decode(header[7:]).decode()))
            pq.revoke(token, "user logged out")
        except: pass
    return {"success": True}

@app.get("/api/profile")
def profile(user=Depends(require_auth)):
    return {"user": user}
Enter fullscreen mode Exit fullscreen mode

Flask works the same way with flask_middleware — the full guide covers both.


Pre-deprecation checklist

Before you remove the JWT fallback:

  • [ ] Confirm your max JWT lifetime has passed (e.g. if JWTs expire in 24h, wait at least 24h after switching new logins to ML-DSA-65)
  • [ ] Check your logs — are there still requests hitting the JWT verification path?
  • [ ] Verify that mobile clients have updated — old app versions might still be sending JWTs
  • [ ] Revoke any long-lived JWTs that were issued manually (service accounts, API clients)
  • [ ] Remove JWT_SECRET and JWT_PUBLIC_KEY from your environment variables
  • [ ] Remove the jsonwebtoken dependency

What you gained

After the migration:

  • Quantum-resistant signatures — ML-DSA-65, NIST FIPS 204
  • Revocation — instantly invalidate any token, no blacklist to build yourself
  • No key management — FIPSign handles key generation, storage, and rotation
  • Same payload structuresub, custom fields, expiry — nothing changes for your application logic

The migration is mostly mechanical. The cryptography underneath is fundamentally different.


If you have questions about specific edge cases — long-lived tokens, service-to-service auth, mobile clients — drop them in the comments.

Top comments (0)