← Back to blog
Launch·8 min read

Hybrid TLS Rollover is live

Turn an Azure App Service TLS finding into a hybrid-bound leaf cert, a staged rollover plan, and a copy-paste azbundle — in one click.

PostQ has been good at finding bad TLS for a while. The cloud scanner happily flags an Azure App Service whose minimum TLS version is 1.0, or whose HTTPS-only switch is off, or whose FTP endpoint is still alive. What it has been badat, until today, is the next sentence: “okay, so what do I do about it?”

The honest answer used to be a paragraph of remediation prose and a link to the Azure portal. Today it’s a button.

What it does

Open any qualifying App Service finding in the dashboard (TLS-1.0-MIN, TLS-1.1-MIN, or HTTPS-NOT-ENFORCED) and click Hybrid TLS rollover. PostQ:

  1. Generates an ephemeral ECDSA-P256 keypair and an ML-DSA-65 keypair, server-side.
  2. Mints a self-signed X.509 leaf cert for the hostname you give it, with a non-critical extension under OID 1.3.9999.42.1 carrying the PQ public key, the ML-DSA-65 signature, and a canonical binding object the signature commits to.
  3. Builds the Azure CLI bundle (bundle.sh, import.sh, bind.sh, rollback.sh, site.bicep) parameterised with the App Service’s real subscription / resource group / site name, recovered from the asset row the scanner wrote.
  4. Builds the four-stage rollover plan (planned → observation → cutover → completed) and persists everything except the classical private key.
  5. Hands the cert and the matching private key back to you, exactly once, in the create response.

PostQ never sees your Azure credentials and never makes a single ARM call against your subscription. The whole rollover is a bash script you run yourself, signed off by your own az session.

Why hybrid?

The leaf is a normal ECDSA-P256 cert. Every TLS 1.2 / 1.3 client validates the chain exactly the way it always has — there is no client opt-in, no library upgrade, nothing to break.

The PQ layer rides shotgun in a non-critical X.509 v3 extension. By definition, every conformant TLS implementation ignores unknown non-critical extensions: the extension is invisible to legacy stacks and breaks nothing. But for a PQ-aware verifier — today, PostQ’s scanner; tomorrow, your client SDK — that extension contains everything needed to confirm the cert is bound to a NIST FIPS 204 ML-DSA-65 signature. When Q-day arrives and ECDSA collapses, you don’t have to re-issue every leaf in a panic. You already have a second, quantum-safe binding.

The binding (and why we don’t sign the TBS)

The obvious thing to PQ-sign is the TBSCertificate bytes — the same blob the classical signature covers. The obvious thing has a chicken-and-egg problem: the TBS contains the extension list, which contains the PQ signature, which is computed over the TBS, which contains… you see where this is going. You can’t commit to the bytes you’re going to embed inside the bytes you’re committing to.

So PostQ signs a bindingobject instead — a deterministic, canonical UTF-8 JSON document that fully identifies the cert without depending on its envelope:

{
  "v":           "PostQ-Hybrid-TLS-v1",
  "alg":         "mldsa65+ecdsa-p256",
  "hostname":    "app.contoso.example",
  "altNames":    ["app.contoso.example", "www.contoso.example"],
  "serial":      "a1b2c3d4…",
  "notBefore":   "2026-05-14T00:00:00.000Z",
  "notAfter":    "2026-08-12T00:00:00.000Z",
  "classicalPub":"BHj…",
  "pqPub":       "AQI…"
}

The binding bytes get signed by ML-DSA-65. The binding, the PQ pub key, and the PQ signature are then DER-packed into a SEQUENCE and embedded as the extension value. A verifier reconstructs the binding from the cert (identity + validity + serial + classical SPKI) plus the embedded pqPub, and checks the signature.

import { ml_dsa65 } from "@noble/post-quantum/ml-dsa";
import { X509Certificate } from "@peculiar/x509";

const cert = new X509Certificate(certPem);
const ext  = parsePostqHybridExt(cert.getExtension("1.3.9999.42.1"));

const ok = ml_dsa65.verify(ext.signature, ext.binding, ext.publicKey);
// ok === true  →  hybrid binding is intact

We smoke-test this every release: classical ECDSA self-sig verifies, ML-DSA-65 verifies over the canonical binding, and a one-byte tamper rejects. All three pass.

The Azure bundle

The whole point of Tier-3 mitigation is that PostQ should not be another thing that needs write access to your cloud. So everything you need to actually do the rollover ships as plain az CLI:

# bundle.sh — the full rollover for one App Service
PFX_PASSWORD="${PFX_PASSWORD:-postq-$(openssl rand -hex 8)}"

openssl pkcs12 -export \
  -in postq-hybrid.pem -inkey postq-hybrid.key \
  -out postq-hybrid.pfx -password "pass:$PFX_PASSWORD" \
  -name "postq-hybrid-app-contoso-example"

az keyvault certificate import \
  --vault-name "contoso-prod-kv" \
  --name "postq-hybrid-app-contoso-example" \
  --file postq-hybrid.pfx --password "$PFX_PASSWORD"

# Grant the App Service's managed identity read access (idempotent).
APP_PRINCIPAL=$(az webapp identity show \
  --resource-group "prod-rg" --name "demo-app" \
  --query principalId -o tsv 2>/dev/null || true)
az keyvault set-policy --name "contoso-prod-kv" \
  --object-id "$APP_PRINCIPAL" \
  --secret-permissions get --certificate-permissions get >/dev/null

az webapp config ssl import \
  --resource-group "prod-rg" --name "demo-app" \
  --key-vault "contoso-prod-kv" \
  --key-vault-certificate-name "postq-hybrid-app-contoso-example"

SHA1=$(openssl x509 -in postq-hybrid.pem -noout -fingerprint -sha1 \
       | cut -d= -f2 | tr -d ':')
az webapp config ssl bind \
  --resource-group "prod-rg" --name "demo-app" \
  --certificate-thumbprint "$SHA1" --ssl-type SNI

# While we're here, force the actual posture fixes the scanner flagged.
az webapp update --resource-group "prod-rg" --name "demo-app" --https-only true
az webapp config set --resource-group "prod-rg" --name "demo-app" \
  --min-tls-version 1.2 --ftps-state FtpsOnly

Required permissions are exactly what they look like: Key Vault Certificates Officer on the target vault and Website Contributor on the App Service. No PostQ principal, no service connection, no cross-tenant trust extended for the rollover.

IaC users get the same rollover as a Bicep snippet for their site module:

resource certRef 'Microsoft.Web/certificates@2022-09-01' = {
  name: 'postq-hybrid-app-contoso-example'
  location: resourceGroup().location
  properties: {
    keyVaultId: resourceId('Microsoft.KeyVault/vaults', 'contoso-prod-kv')
    keyVaultSecretName: 'postq-hybrid-app-contoso-example'
    serverFarmId: site.properties.serverFarmId
  }
}

resource binding 'Microsoft.Web/sites/hostNameBindings@2022-09-01' = {
  parent: site
  name: 'app.contoso.example'
  properties: {
    sslState: 'SniEnabled'
    thumbprint: certRef.properties.thumbprint
    hostNameType: 'Verified'
  }
}

The rollover plan

Slamming a new cert onto a production App Service is a way to get paged at 3am. Every rollover walks a four-stage state machine, surfaced in the dashboard:

  1. planned— cert minted, sidecar signed, bundle delivered. No Azure state changed.
  2. observation— the new cert is bound with SNI alongside any existing binding. Watch handshake error rates and PostQ ledger entries for the configured window (default 24h).
  3. cutover— the hybrid cert is promoted to the primary binding for the hostname; HTTPS-only and TLS 1.2 minimum get enforced. PostQ’s scanner re-checks the endpoint within 60s and writes a ledger event.
  4. completed— old TLS-1.0/1.1 minimum and any legacy cert retired. Hybrid binding is the source of truth.

From any non-terminal state you can roll back. The terminal rolled_backstatus leaves the previous binding intact — rollback.sh just unbinds the new cert from the hostname and disables the new cert version in Key Vault.

What gets stored, and what doesn’t

The classical private key is generated inside the API process, handed back to you in the create response, and then it’s gone. PostQ persists:

  • The leaf cert PEM.
  • Both public keys (classical 65 bytes; ML-DSA-65 raw bytes).
  • The detached ML-DSA-65 signature.
  • The rollover plan + the generated scripts (for re-download).
  • The Azure resource id of the App Service, so the scanner can re-verify the binding on future scans.

That is enough to verify the binding on a live TLS handshake but never to forge one. Every state change writes a tamper-evident entry to the PostQ Ledger: creation, transition, rollback. Compliance gets a hash-chained audit trail of who did what, when, with which cert thumbprint.

API surface

The same flow is available over the REST API for CI / IaC pipelines. Auth is the standard pq_live_… bearer token.

# 1. Mint the hybrid cert from a TLS finding.
ROLLOVER=$(curl -fsS -X POST https://api.postq.dev/v1/mitigations/tls-rollover \
  -H "Authorization: Bearer $POSTQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "findingId": "8b3a…-uuid",
    "hostname":  "app.contoso.example"
  }')

# 2. Save artifacts (one-shot delivery — the .key is shown ONCE).
echo "$ROLLOVER" | jq -r .data.artifacts.certPem > postq-hybrid.pem
echo "$ROLLOVER" | jq -r .data.artifacts.keyPem  > postq-hybrid.key
ID=$(echo "$ROLLOVER" | jq -r .data.id)

# 3. Run import + bind, then advance the plan.
bash <(curl -fsS \
  -H "Authorization: Bearer $POSTQ_API_KEY" \
  "https://api.postq.dev/v1/mitigations/tls-rollover/$ID/download?artifact=bundle.sh")

curl -fsS -X POST https://api.postq.dev/v1/mitigations/tls-rollover/$ID/transition \
  -H "Authorization: Bearer $POSTQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "cutover" }'

Full reference, including the verification recipe and the full state machine, lives in Docs → Mitigations → Hybrid TLS Rollover.

What’s next

App Service is the first cloud target because it’s where the demos kept asking for it. The plumbing — binding-canonical PQ signatures, one-shot key delivery, staged rollovers, generated scripts — is provider-agnostic. AWS Application Load Balancer and Front Door are the next two on the list, in that order.

And once draft-ietf-lamps-pq-composite-sigs lands, swapping the PostQ-private OID for the standardised composite-signature OID is a one-line change in services/mitigation/hybrid-tls.ts. The rest of the stack — the scripts, the lifecycle, the ledger entries, the dashboard — doesn’t need to know.

Live in production at app.postq.dev on every Azure App Service finding, today. Open a scan, click the finding, click Hybrid TLS rollover.