Signing-key certificates
By default every token-signing key carries a self-signed certificate. Nothing in the certificate links the key to a tenant, and the JWKS publishes only the public key.
That's fine while every tenant shares the control-plane keys: one JWKS covers everything. It breaks down with per-tenant keys and custom domains. A token's iss can be any tenant subdomain or custom domain, and a resource server can't safely fetch ${iss}/.well-known/jwks.json for an arbitrary issuer. Anyone can host a JWKS on a domain they control. So today the resource server needs its own issuer → tenant registry, kept in sync with AuthHero's custom domains.
With a signing certificate authority, AuthHero issues each jwt_signing certificate from an intermediate CA you provide. The certificate names its owner in a SubjectAlternativeName URI, and the JWKS publishes the chain. A resource server pins one root and needs no registry.
Configuration
import { init, createLocalCertificateIssuer } from "authhero";
const app = init({
dataAdapter,
signingCertificateAuthority: {
issuer: createLocalCertificateIssuer({
certificate: env.SIGNING_CA_CERT, // intermediate, PEM
privateKey: env.SIGNING_CA_KEY, // intermediate's PKCS#8 key, PEM
}),
validityDays: 30, // default
},
});Keep the root offline. AuthHero only needs the intermediate.
issuer is any object that implements CertificateIssuer:
interface CertificateIssuer {
// Certify a public key; returns the leaf certificate as PEM.
issueCertificate(request: SigningCertificateRequest): Promise<string>;
// Every intermediate that may have signed a still-published leaf.
getIssuerCertificates(): Promise<string[]>;
}The request is plain data: an SPKI public-key PEM, the subject, the SAN URI and the validity window. That means an issuer can forward it to a separate service, so the CA key never has to live in the auth worker. AuthHero generates the key pair itself and only ever sends the public key. It also checks that the returned certificate is for that key.
When you rotate the intermediate, list the old one in previousCertificates until the leaves it signed have been rotated or renewed. Otherwise those keys are published without a chain.
What gets issued
Each leaf certificate has:
BasicConstraintsCA=false, andKeyUsagedigitalSignature only.- A SAN URI naming the owner. Tenant keys get
urn:authhero:tenant:<tenant_id>, and control-plane keys geturn:authhero:control-plane. The certificate holds no hostnames, so adding or removing a custom domain never requires a new certificate. - A short lifetime, 30 days by default. Renewing keeps the key pair, so the
kidstays the same and tokens that were already issued keep verifying.
Set subjectUri to change the SAN. For example, a WFP tenant worker stores its own keys without a tenant_id, so it has to name its tenant explicitly:
signingCertificateAuthority: {
issuer,
subjectUri: () => `urn:authhero:tenant:${TENANT_ID}`,
}SAML keys are never CA-issued. Service providers pin SAML certificates for years and don't use the chain.
The JWKS entry for a CA-issued key gets:
x5c: the leaf and its intermediate, as base64 DER, leaf first (RFC 7517 §4.7).x5t#S256: the SHA-256 thumbprint of the leaf.
AuthHero picks the intermediate by matching the issuer name and then verifying the leaf's signature. If no known intermediate signed the leaf, the key is published without a chain, because a wrong chain would be worse than none.
Verifying tokens at a resource server
- Fetch the JWKS from the token's
iss, whatever host it is. - Find the key by
kid, and validate itsx5cchain up to your pinned root: validity dates, key usage, and the CA flags. - Read the leaf's SAN URI. Accept the key if it's
urn:authhero:tenant:<tenant_id>with the token'stenant_idclaim, orurn:authhero:control-plane. - Verify the JWS signature with the leaf's key.
No issuer allowlist or custom-domain registry is needed. A JWKS hosted by someone else can't produce a chain to your root.
The token is unverified when step 1 runs, so its iss decides which URL your API fetches. Guard that fetch:
- Fetch over HTTPS only, and refuse private and loopback addresses. Use a short timeout and cap the response size.
- Cache each issuer's JWKS for about five minutes, and cap how many issuers you keep. Otherwise random
issvalues fill the cache. - Refetch on an unknown
kidat most once a minute per issuer. - Make authorization decisions on the token's
tenant_idandaud. Without a registry, a custom-domainisscan't be tied to a tenant, so treat it as informational.
A stolen private key and its still-valid certificate chain to your root wherever they are hosted, so revocation takes effect when the certificate expires. Keep lifetimes short. Renewal is cheap, because it keeps the key.
Renewal
CA-issued certificates are short-lived, so renew them on a schedule. renewSigningCertificates re-issues every CA-issued jwt_signing certificate that expires soon. It keeps the key pair, so the kid and every token already issued stay valid:
import { renewSigningCertificates } from "authhero";
export default {
async scheduled(_event, env) {
await renewSigningCertificates({
dataAdapter,
certificateAuthority: signingCertificateAuthority, // same value as init()
});
},
};- When it renews: certificates expiring within
renewBeforeDays. The default is a third ofvalidityDays, which is 10 days for the 30-day default. Run it at least daily. - What it skips: self-signed keys and public-only rows. Keys in their post-rotation grace period are renewed, because they're still published.
- Failures: every key is tried. If any fail, a
SigningCertificateRenewalErrorcarrying the full result is thrown at the end, so the cron run shows as failed.
Running the CA as a separate service
createLocalCertificateIssuer keeps the intermediate's private key in the auth worker. To keep it elsewhere, serve the issuer from its own worker and point AuthHero at it:
// CA service
import {
createCertificateIssuerApp,
createLocalCertificateIssuer,
} from "authhero";
export default createCertificateIssuerApp({
issuer: createLocalCertificateIssuer({ certificate, privateKey }),
authorize: async ({ request, certificateRequest }) => {
const caller = await authenticateCaller(request); // your credential check
return certificateRequest.uri === `urn:authhero:tenant:${caller.tenantId}`;
},
maxValidityDays: 90, // default
});// Auth worker
signingCertificateAuthority: {
issuer: createHttpCertificateIssuer({
url: "https://ca.internal.example.com",
headers: { authorization: `Bearer ${env.CA_TOKEN}` },
}),
}authorize is the only thing stopping a caller from getting a certificate that names another tenant, or the control plane, so tie the caller's credential to the URI it may request. maxValidityDays caps the lifetime a caller can ask for. GET /issuer-certificates isn't authorized, because the intermediates are published in every x5c anyway. The client caches the answer for five minutes and never caches a failure.
Rolling it out
- Existing keys are unaffected. Keys created before the CA was configured stay self-signed and are published without
x5c. They keep verifying as before until they're rotated out. - New keys are CA-issued. This covers keys created by rotation, by revoke-and-replace, and by
ensureSigningKeywhen you pass itcertificateAuthority. It applies to control-plane keys as well as tenant keys. - Renewal re-issues from the CA.
POST /api/v2/keys/signing/{kid}/renewre-issues the certificate from the CA and keeps thekid. - Control-plane keys stay trusted. Because they're CA-issued too, moving tenants from the shared key to their own keys with
signingKeyModeneeds no change at the resource server.
Workers for Platforms tenants
A WFP tenant worker stores its own keys without a tenant_id, so it has to be told which tenant it is. Otherwise its certificates would name the control plane, which resource servers trust for every tenant. createWfpTenantApp takes the tenant explicitly:
createWfpTenantApp({
createDataAdapter,
signingCertificateAuthority: (env) => ({
issuer: createHttpCertificateIssuer({
url: env.SIGNING_CA_URL,
headers: { authorization: `Bearer ${env.SIGNING_CA_TOKEN}` },
}),
tenantId: env.TENANT_ID, // set per tenant by your provisioner's `secrets`
}),
});- Issuance:
sync-defaultsmints the tenant's key from the CA when the tenant has no signing key yet. - Rotation, renewal and the JWKS: these use the same CA.
- Credentials: give each tenant its own CA credential, and have the CA service's
authorizeaccept only that tenant's URN.
Tenant workers in a dispatch namespace have no schedule of their own, so the control plane pushes renewal to them from its own scheduled handler:
import { createDispatchRenewSigningCertificates } from "@authhero/cloudflare-adapter/wfp";
const renew = createDispatchRenewSigningCertificates({
dispatcher: env.DISPATCHER,
internalSecret: env.WFP_INTERNAL_SYNC_SECRET,
});
const failures: Error[] = [];
for (const tenantId of wfpTenantIds) {
try {
await renew(tenantId); // POST /internal/renew-signing-certificates
} catch (cause) {
failures.push(
new Error(`Certificate renewal failed for tenant ${tenantId}`, { cause }),
);
}
}
if (failures.length > 0) {
throw new AggregateError(
failures,
"Some tenant certificates could not be renewed",
);
}Current limitations
- Seeded keys are self-signed. The first key created by
seed()is self-signed; rotate it once the CA is configured.