Skip to content
ecdsa.com

Self-signed certificate · Node.js

How to create a self-signed EC P-256 certificate with SANs in Node.js

node:crypto can create keys but not certificates, so Node.js code builds one with @peculiar/x509: X509CertificateGenerator.createSelfSigned takes a WebCrypto key pair, the name, the validity and the extensions, and signs with ECDSA P-256 and SHA-256. A second script starts an HTTPS server with the result and requests a page from it with a client that trusts exactly this certificate.

Tested with @peculiar/x509 2.2.0 (Node.js 24.22.0, reflect-metadata 0.2.2).

cert.mjs — P-256 key and self-signed server certificate with @peculiar/x509
import "reflect-metadata"; // @peculiar/x509 2.x needs a Reflect polyfill, imported first
import { writeFileSync } from "node:fs";
import * as x509 from "@peculiar/x509";
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 notAfter = new Date(notBefore.getTime() + 365 * 24 * 60 * 60 * 1000);
// Without serialNumber, the library picks a random positive 128-bit serial
const cert = await x509.X509CertificateGenerator.createSelfSigned({
name: "CN=localhost",
notBefore,
notAfter,
keys,
signingAlgorithm: algorithm,
extensions: [
new x509.SubjectAlternativeNameExtension([
{ type: "dns", value: "localhost" },
{ type: "ip", value: "127.0.0.1" },
]),
new x509.BasicConstraintsExtension(false, undefined, true), // CA:FALSE, critical
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.serverAuth]),
],
});
const pkcs8 = await crypto.subtle.exportKey("pkcs8", keys.privateKey);
writeFileSync("key.pem", x509.PemConverter.encode(pkcs8, "PRIVATE KEY"));
writeFileSync("cert.pem", cert.toString("pem"));
use.mjs — HTTPS server with the certificate and a client that trusts it
// Use it right away: an HTTPS server, and a client that trusts exactly this certificate
import { readFileSync } from "node:fs";
import https from "node:https";
const server = https.createServer({ key: readFileSync("key.pem"), cert: readFileSync("cert.pem") }, (req, res) => res.end("hello over TLS\n"));
server.listen(8443, "127.0.0.1", () => {
https.get("https://localhost:8443/", { ca: readFileSync("cert.pem") }, (res) => {
res.pipe(process.stdout); // stdout never emits "finish": wait for the response's "end"
res.on("end", () => server.close());
});
});

How it works

  1. crypto.subtle.generateKey with { name: "ECDSA", namedCurve: "P-256" } creates an exportable key pair; the same algorithm object, with hash: "SHA-256", is the signingAlgorithm.
  2. createSelfSigned uses name as both subject and issuer; without serialNumber, it generates a random positive 16-byte serial.
  3. The extensions are objects: SubjectAlternativeNameExtension with dns and ip entries, BasicConstraintsExtension(false, undefined, true) for a critical CA:FALSE, KeyUsagesExtension(…, true) and ExtendedKeyUsageExtension([serverAuth]).
  4. In use.mjs, https.createServer({ key, cert }) serves TLS with the two files, and https.get(…, { ca }) trusts this certificate as its only root; localhost matches the DNS SAN. The server closes on the response's end event — piping into process.stdout never emits finish.

Gotchas

  • A client without ca rejects the server with DEPTH_ZERO_SELF_SIGNED_CERT: self-signed certificate; if the root CA is installed locally, try running Node.js with --use-system-ca. NODE_EXTRA_CA_CERTS=cert.pem makes both https and fetch() trust it without code changes.
  • A DNS SAN does not cover addresses: connecting to 127.0.0.1 with a certificate that lacks the IP SAN fails with ERR_TLS_CERT_ALTNAME_INVALID: Hostname/IP does not match certificate's altnames: IP: 127.0.0.1 is not in the cert's list.
  • NODE_TLS_REJECT_UNAUTHORIZED=0 hides the error by switching off certificate checks for every connection of the process; Node.js warns that it makes TLS connections and HTTPS requests insecure by disabling certificate verification.
  • @peculiar/x509 2.x needs a Reflect polyfill: without import "reflect-metadata" before the library, it throws tsyringe requires a reflect polyfill.

Related recipes