Summary
.env.example tells operators the EdDSA public key is optional and "can be derived from
private key". It cannot. Booting with JWT_PRIVATE_KEY set and JWT_PUBLIC_KEY unset throws
Failed to derive public key from private key. Please provide JWT_PUBLIC_KEY in environment variables. and the server never starts.
The repo already contains the correct helper for this, sitting next to the broken call site,
and its doc comment already explains why the broken approach cannot work.
Reproduction
Set a valid PKCS#8 Ed25519 JWT_PRIVATE_KEY, leave JWT_PUBLIC_KEY and
JWT_PUBLIC_KEY_PATH unset, and start the server. It throws during jwtPlugin registration.
Found incidentally while writing the openapi-export script for the documentation site
(#347): the first run failed on this, with no live services involved, so it is not
environment-specific.
Technical Details
libs/fastify/plugins/jwt/src/lib/fastify-plugin-jwt.ts:85-96 derives the public half via
exportPublicKeyPem(privateKey):
// Derive public key from private key
// For EdDSA keys, we can export the public key from the private key
// Note: This requires the key to be extractable, which imported keys are by default in jose
try {
const publicKeyPem = await exportPublicKeyPem(privateKey);
publicKey = await importPublicKey(publicKeyPem);
} catch {
throw new Error(
'Failed to derive public key from private key. Please provide JWT_PUBLIC_KEY in environment variables.'
);
}
exportPublicKeyPem (libs/server/jwt/src/lib/jose-utils.ts:77-79) is a direct pass-through
to jose's exportSPKI, called on an imported private key. That comment's premise — that
imported keys are extractable in jose — is what is wrong.
The codebase already documents the correct answer.
libs/server/jwt/src/lib/key-management.ts:91-95, on derivePublicKeyPemFromPrivate:
Uses node:crypto's createPublicKey, which computes ONLY the public half — no private
material is ever emitted. This is the deliberate path (rather than jose's exportSPKI)
because jose 6 imports private keys as NON-extractable WebCrypto CryptoKeys, from which
the public key cannot be re-exported; createPublicKey derives it directly from the PEM.
That helper is algorithm-agnostic and is already used for the RS256 path (#309). The EdDSA
path never got switched over, so the catch block converts a guaranteed failure into a
misleading "please provide the key" message — which reads as operator error rather than a
defect.
Note also that the derive call receives the imported KeyLike, whereas
derivePublicKeyPemFromPrivate takes the private-key PEM. The fix therefore needs the
raw options.privateKey string, which the plugin already has in scope.
Documentation that promises the broken behaviour
.env.example:191-193 — "EdDSA public key in PEM format (optional …) / If not provided, can be derived from private key"
.env.example:207-209 — same claim for JWT_PUBLIC_KEY_PATH
.env.example:213 — "Note: Provide either JWT_PUBLIC_KEY or JWT_PUBLIC_KEY_PATH (both are optional)"
docs/oidf-op-certification-runbook.md — "QAuth derives the public key from the private key if you omit it."
Either the code or all four statements are wrong. Deriving is the better behaviour and is
already implemented, so the code should be fixed rather than the docs weakened.
Tasks
Acceptance Criteria
References
Summary
.env.exampletells operators the EdDSA public key is optional and "can be derived fromprivate key". It cannot. Booting with
JWT_PRIVATE_KEYset andJWT_PUBLIC_KEYunset throwsFailed to derive public key from private key. Please provide JWT_PUBLIC_KEY in environment variables.and the server never starts.The repo already contains the correct helper for this, sitting next to the broken call site,
and its doc comment already explains why the broken approach cannot work.
Reproduction
Set a valid PKCS#8 Ed25519
JWT_PRIVATE_KEY, leaveJWT_PUBLIC_KEYandJWT_PUBLIC_KEY_PATHunset, and start the server. It throws duringjwtPluginregistration.Found incidentally while writing the
openapi-exportscript for the documentation site(#347): the first run failed on this, with no live services involved, so it is not
environment-specific.
Technical Details
libs/fastify/plugins/jwt/src/lib/fastify-plugin-jwt.ts:85-96derives the public half viaexportPublicKeyPem(privateKey):exportPublicKeyPem(libs/server/jwt/src/lib/jose-utils.ts:77-79) is a direct pass-throughto jose's
exportSPKI, called on an imported private key. That comment's premise — thatimported keys are extractable in jose — is what is wrong.
The codebase already documents the correct answer.
libs/server/jwt/src/lib/key-management.ts:91-95, onderivePublicKeyPemFromPrivate:That helper is algorithm-agnostic and is already used for the RS256 path (#309). The EdDSA
path never got switched over, so the
catchblock converts a guaranteed failure into amisleading "please provide the key" message — which reads as operator error rather than a
defect.
Note also that the derive call receives the imported
KeyLike, whereasderivePublicKeyPemFromPrivatetakes the private-key PEM. The fix therefore needs theraw
options.privateKeystring, which the plugin already has in scope.Documentation that promises the broken behaviour
.env.example:191-193— "EdDSA public key in PEM format (optional …) / If not provided, can be derived from private key".env.example:207-209— same claim forJWT_PUBLIC_KEY_PATH.env.example:213— "Note: Provide either JWT_PUBLIC_KEY or JWT_PUBLIC_KEY_PATH (both are optional)"docs/oidf-op-certification-runbook.md— "QAuth derives the public key from the private key if you omit it."Either the code or all four statements are wrong. Deriving is the better behaviour and is
already implemented, so the code should be fixed rather than the docs weakened.
Tasks
fastify-plugin-jwt.tstoderivePublicKeyPemFromPrivate(options.privateKey)catch {}so a genuine failure surfaces its cause instead of being relabelled as missing configurationexportPublicKeyPeminjose-utils.tsstill has a legitimate caller; if not, remove it so the trap cannot be re-enteredAcceptance Criteria
.env.exampledocuments (private key only) boots and verifies tokensReferences
libs/server/jwt/src/lib/key-management.ts:91-106— the correct helper and the explanationderivePublicKeyPemFromPrivate