nginx
Is PQC enabled? — quick check
macOS / Linux
# 1) No-dependency check — identify this machine first.
uname -a 2>/dev/null || true
# 2) Dependency check — prompt before installing anything.
if ! command -v openssl >/dev/null 2>&1; then
echo 'OpenSSL was not found. A local PQC proof needs OpenSSL 3.5+.'
printf 'Install OpenSSL now? [y/N] '
read answer
case "$answer" in
[Yy]*)
if command -v brew >/dev/null 2>&1; then brew install openssl@3
elif command -v apt-get >/dev/null 2>&1; then sudo apt-get update && sudo apt-get install -y openssl
elif command -v dnf >/dev/null 2>&1; then sudo dnf install -y openssl
elif command -v yum >/dev/null 2>&1; then sudo yum install -y openssl
else echo 'No supported package manager found. Install OpenSSL 3.5+ and retry.'; exit 1
fi ;;
*) echo 'Install OpenSSL 3.5+ and retry for a local PQC proof.'; exit 1 ;;
esac
fi
OPENSSL=openssl
if command -v brew >/dev/null 2>&1; then
BREW_OPENSSL="$(brew --prefix openssl@3 2>/dev/null)/bin/openssl"
[ -x "$BREW_OPENSSL" ] && OPENSSL="$BREW_OPENSSL"
fi
$OPENSSL version
if ! $OPENSSL list -tls-groups 2>/dev/null | grep -qiE 'X25519MLKEM768|MLKEM|Kyber'; then
echo 'This OpenSSL does not advertise ML-KEM groups. Upgrade to OpenSSL 3.5+ or load oqsprovider, then retry.'
exit 1
fi
$OPENSSL list -kem-algorithms 2>/dev/null | grep -iE 'mlkem|kyber' || echo 'no native ML-KEM (try "$OPENSSL list -providers" for oqsprovider)'
# 3) Live handshake against your server. The 'Negotiated TLS1.3 group' line
# (or 'Server Temp Key' on OpenSSL 3.5) tells you what was negotiated
# end-to-end — no external service required.
$OPENSSL s_client -connect example.com:443 -tls1_3 -groups X25519MLKEM768 </dev/null 2>&1 |
grep -E 'Negotiated TLS1\.3 group|Server Temp Key|Cipher is|alert' Expected when PQC is ON
OpenSSL 3.6.2 7 Apr 2026
X25519MLKEM768 @ default
Negotiated TLS1.3 group: X25519MLKEM768
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384 What you'll see when PQC is OFF
OpenSSL 3.0.13 30 Jan 2024
no native ML-KEM
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Server Temp Key: X25519, 253 bits To enable PQ on your server, see the nginx, Apache, or Caddy KB pages — it's one line: ssl_ecdh_curve / SSLOpenSSLConfCmd Groups / tls.curve_preferences = X25519MLKEM768:X25519:secp384r1.
Windows PowerShell
# 1) No-dependency check — identify this machine first.
[System.Environment]::OSVersion.Version
Get-ComputerInfo | Select-Object OsName,OsVersion,OsBuildNumber
# 2) Dependency check — prompt before installing anything.
function Confirm-Install($Message) {
$answer = Read-Host "$Message Install now? [y/N]"
return ($answer -match '^[Yy]')
}
function Get-OpenSSLPath {
$cmd = Get-Command openssl.exe -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
$programFilesX86 = [Environment]::GetEnvironmentVariable('ProgramFiles(x86)')
$currentPath = (Get-Location).Path
$candidateDirs = @(
$(if ($env:ProgramFiles) { Join-Path (Join-Path $env:ProgramFiles 'OpenSSL-Win64') 'bin' }),
$(if ($programFilesX86) { Join-Path (Join-Path $programFilesX86 'OpenSSL-Win32') 'bin' }),
$(if ($env:LOCALAPPDATA) { Join-Path (Join-Path (Join-Path $env:LOCALAPPDATA 'Programs') 'OpenSSL-Win64') 'bin' }),
$currentPath,
$(if ($currentPath) { Join-Path (Join-Path $currentPath 'openssl') 'bin' })
)
foreach ($dir in $candidateDirs) {
if (-not $dir) { continue }
$candidate = Join-Path $dir 'openssl.exe'
if ($candidate -and (Test-Path -LiteralPath $candidate -PathType Leaf)) { return $candidate }
}
return $null
}
$openssl = Get-OpenSSLPath
if (-not $openssl) {
Write-Warning 'OpenSSL 3.5+ was not found. Built-in Windows PowerShell/Schannel cannot prove the PQC named group.'
if ((Get-Command winget -ErrorAction SilentlyContinue) -and (Confirm-Install 'Install ShiningLight OpenSSL with winget?')) {
winget install ShiningLight.OpenSSL.Light
$openssl = Get-OpenSSLPath
}
if (-not $openssl) { 'Install or pre-stage OpenSSL 3.5+ and retry.'; return }
}
& $openssl version
$groups = (& $openssl list -tls-groups 2>$null) -join [Environment]::NewLine
if ($groups -notmatch 'X25519MLKEM768|MLKEM|Kyber') {
'Local OpenSSL does not advertise ML-KEM groups. Install or pre-stage OpenSSL 3.5+ and retry.'
return
}
& $openssl list -kem-algorithms | Select-String -Pattern 'mlkem|kyber'
# 3) Live handshake — fully local, no api.checkpqc.app needed.
& $openssl s_client -connect example.com:443 -tls1_3 -groups X25519MLKEM768 2>&1 |
Select-String -Pattern 'Negotiated TLS1\.3 group|Server Temp Key|Cipher is|alert' Expected when PQC is ON
OpenSSL 3.6.2 7 Apr 2026
X25519MLKEM768 @ default
Negotiated TLS1.3 group: X25519MLKEM768
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384 What you'll see when PQC is OFF
OpenSSL 1.1.1w 11 Sep 2023
# (no mlkem line)
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
# (no group line — 1.1.1 cannot offer ML-KEM) Schannel cannot expose the negotiated TLS named group programmatically, so a local OpenSSL binary is the most reliable offline answer on Windows.
nginx negotiates TLS through OpenSSL. To negotiate the hybrid post-quantum group
X25519MLKEM768 (IANA codepoint 0x11EC) you need either OpenSSL 3.5+
(which ships ML-KEM natively) or OpenSSL 3.x linked against the
oqsprovider module.
1. Verify your OpenSSL
nginx -V 2>&1 | grep -i openssl
if ! command -v openssl >/dev/null 2>&1; then
echo 'OpenSSL was not found. Install OpenSSL 3.5+ before verifying nginx PQC groups.'
printf 'Install OpenSSL now? [y/N] '; read answer
case "$answer" in [Yy]*) sudo apt-get update && sudo apt-get install -y openssl ;; *) exit 1 ;; esac
fi
openssl list -providers
You should see oqsprovider active or OpenSSL ≥ 3.5. If your distro ships 3.0/3.3,
build a side-by-side OpenSSL into /opt/openssl-pqc and point nginx at it via
--with-cc-opt/--with-ld-opt.
2. Configure groups
In your http { block (or per-vhost), set the TLS 1.3 group order. nginx applies
ssl_ecdh_curve as the TLS 1.3 named-group list:
ssl_protocols TLSv1.3 TLSv1.2;
ssl_ecdh_curve X25519MLKEM768:SecP256r1MLKEM768:X25519:secp384r1:prime256v1;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; 3. Load oqsprovider in systemd
If you use a side-by-side OpenSSL, point nginx at its config and modules:
# /etc/systemd/system/nginx.service.d/pqc.conf
[Service]
Environment=OPENSSL_CONF=/opt/openssl-pqc/ssl/openssl.cnf
Environment=OPENSSL_MODULES=/opt/openssl-pqc/lib64/ossl-modules 4. Reload and verify
sudo nginx -t && sudo systemctl reload nginx
if ! command -v openssl >/dev/null 2>&1; then
echo 'OpenSSL was not found. Install OpenSSL 3.5+ before running the local proof.'
printf 'Install OpenSSL now? [y/N] '; read answer
case "$answer" in [Yy]*) sudo apt-get update && sudo apt-get install -y openssl ;; *) exit 1 ;; esac
fi
openssl s_client -connect example.com:443 -tls1_3 \
-groups X25519MLKEM768 </dev/null 2>&1 | grep "Cipher is\|alert" Gotcha
A child location with any add_header drops every parent add_header
from that location. If you set HSTS at the server level, re-add it inside any location that
adds its own headers.
Background — what's actually on the wire
X25519MLKEM768 (IANA TLS named-group 0x11EC) is a hybrid KEM that
concatenates an X25519 ECDH share with an ML-KEM-768 KEM share. The TLS 1.3 client offers
it in supported_groups; the server picks it if it's first in its preference
list. nginx exposes that preference via ssl_ecdh_curve (groups list) and the
newer ssl_conf_command Groups directive.
Operational notes
- Hybrid handshakes add roughly 1.1 KB to
ClientHelloand 1.1 KB toServerHello— verify yourlarge_client_header_buffersis ≥ 4 8k (default) so it survives. - Some legacy WAFs (older AWS WAF Classic, F5 ASM) silently drop large ClientHellos;
watch for
SSL_R_PACKET_LENGTH_TOO_LONGalerts inerror_log. - OCSP stapling, session tickets, and HSTS are unrelated to the named group — do them per OpenSSL guidance.
- Reload (
nginx -s reload) is enough — no restart needed for group changes.