Skip to content
ecdsa.com

Self-signed certificate · WebCrypto

How to create a self-signed EC P-256 certificate with SANs in WebCrypto

WebCrypto generates keys and signs, but has no X.509 encoder, so the recipe uses @peculiar/x509, a library built on WebCrypto: createSelfSigned takes the key pair, the name, the validity and the extensions and returns the signed certificate. The function runs unchanged in browsers; the run executes it with the WebCrypto of Node.js, saves the PEM files and verifies the certificate with OpenSSL.

Tested against the WebCrypto API in @peculiar/x509 2.2.0 (WebCrypto in Node.js 24.22.0, reflect-metadata 0.2.2) — the same crypto.subtle interface browsers implement.

createSelfSigned() — P-256 key and self-signed server certificate in the browser
import "reflect-metadata"; // @peculiar/x509 2.x needs a Reflect polyfill, imported first
import * as x509 from "@peculiar/x509"; // built on WebCrypto; works in browsers unchanged
async function createSelfSigned() {
const algorithm = { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" };
const keys = await crypto.subtle.generateKey(algorithm, true, ["sign", "verify"]);
const notBefore = new Date();
const cert = await x509.X509CertificateGenerator.createSelfSigned({
name: "CN=localhost", // no serialNumber: a random positive 128-bit one is generated
notBefore,
notAfter: new Date(notBefore.getTime() + 365 * 86_400_000),
keys,
signingAlgorithm: algorithm,
extensions: [
new x509.SubjectAlternativeNameExtension([{ type: "dns", value: "localhost" }, { type: "ip", value: "127.0.0.1" }]),
new x509.BasicConstraintsExtension(false, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.serverAuth]),
],
});
const pkcs8 = await crypto.subtle.exportKey("pkcs8", keys.privateKey);
return { certPem: cert.toString("pem"), keyPem: x509.PemConverter.encode(pkcs8, "PRIVATE KEY") };
}

How it works

  1. crypto.subtle.generateKey makes a P-256 key pair; the second argument, true, makes it extractable so that the private key can be saved.
  2. X509CertificateGenerator.createSelfSigned signs with the private key and uses name as subject and issuer; notAfter is notBefore plus 365 days in milliseconds.
  3. The extensions are passed as objects; their last argument, true, marks basicConstraints and keyUsage as critical.
  4. exportKey("pkcs8") with PemConverter.encode(…, "PRIVATE KEY") gives key.pem and cert.toString("pem") gives cert.pem — ready for a download link or a local development server.

Gotchas

  • WebCrypto's ECDSA sign returns the raw r‖s pair — 64 bytes for P-256 — while X.509 needs a DER-encoded signature; @peculiar/x509 converts it, which is one reason not to assemble certificates by hand.
  • A key generated with extractable: false cannot be saved: exportKey fails with an InvalidAccessError (key is not extractable in Node.js). That fits a certificate that only lives in this browser, with the CryptoKey kept in IndexedDB.
  • serialNumber is a hex string. Leaving it out, or passing an all-zero value, makes the library pick 16 random bytes; a fixed value such as "01" is used as is, and RFC 5280 requires serials to be unique per issuer.
  • The library needs a Reflect polyfill: import reflect-metadata before @peculiar/x509, or it throws at load time with tsyringe requires a reflect polyfill.

Related recipes