Digitally Signing Files with Node.js

Digitally Signing Files with Node.js

A digital signature answers two questions that a checksum alone cannot: who produced this file, and has it changed since. A SHA-256 hash tells you whether the bytes match a value you already trust, but it does not tell you where that value came from. Anyone who can tamper with a file can usually tamper with the hash published next to it. A signature binds the content to a private key that only the signer holds, and anyone with the corresponding public key can verify that binding without being able to forge it.

This article walks through the whole process in Node.js: choosing an algorithm, generating and storing keys, signing small and very large files, producing detached signatures, packaging everything into a portable envelope, and avoiding the mistakes that quietly turn a signature into decoration.

How File Signing Actually Works

Signing a file does not mean encrypting the file with a private key. That mental model is a leftover from textbook RSA and it breaks down as soon as you use ECDSA or Ed25519, where nothing resembling encryption happens at all. The accurate model is:

  1. The file is reduced to a fixed-size digest by a cryptographic hash function, typically SHA-256 or SHA-512.
  2. The digest is fed, together with the private key, into a signature algorithm that produces a signature value.
  3. A verifier recomputes the digest from the file it received, and feeds the digest, the signature, and the public key into a verification algorithm that returns only true or false.

The security properties follow from this structure. Collision resistance of the hash prevents an attacker from finding a second file with the same digest, which would make one signature valid for two documents. The hardness of the underlying mathematical problem prevents the attacker from producing a valid signature without the private key. And because only the digest is signed, the cost of signing is essentially independent of file size.

Choosing an Algorithm

Node.js exposes RSA, ECDSA, Ed25519, and Ed448 through the built-in crypto module. For new systems the practical choice is between three options.

Ed25519

Ed25519 is an EdDSA signature scheme over Curve25519. Signatures are always 64 bytes, public keys are 32 bytes, signing and verification are fast, and the algorithm is deterministic: signing the same message twice with the same key yields the same signature. Determinism matters because it removes the dependency on a good random number generator at signing time, which is exactly what destroyed several ECDSA deployments in the past. Unless you have an interoperability constraint, Ed25519 should be the default.

ECDSA with P-256

Choose ECDSA when you need to interoperate with systems that expect NIST curves: X.509 certificate chains, JWT with the ES256 algorithm, hardware security modules, TPMs, or smart cards. Signatures are compact but variable length in DER encoding, which occasionally surprises people who allocate fixed-size buffers.

RSA-PSS

RSA remains everywhere in existing infrastructure. If you use it, use PSS padding rather than the legacy PKCS#1 v1.5 padding. PSS has a security proof, v1.5 does not, and while v1.5 signatures are not broken today, there is no reason to choose them for greenfield work. Use a modulus of at least 3072 bits; 2048 is acceptable for compatibility but is no longer a comfortable margin for data with a long lifetime.

The tradeoff table, condensed: Ed25519 for anything you control end to end, ECDSA P-256 for standards interoperability, RSA-PSS for legacy ecosystems and for the rare case where you need a verifier that is much simpler than a signer.

Generating a Key Pair

Node.js provides crypto.generateKeyPair and its synchronous and promise-based variants. The asynchronous version runs on the thread pool and does not block the event loop, which matters for RSA where key generation can take hundreds of milliseconds or more.

import { generateKeyPair } from 'node:crypto';
import { promisify } from 'node:util';
import { writeFile } from 'node:fs/promises';

const generateKeyPairAsync = promisify(generateKeyPair);

async function createEd25519KeyPair(passphrase) {
  const { publicKey, privateKey } = await generateKeyPairAsync('ed25519', {
    publicKeyEncoding: {
      type: 'spki',
      format: 'pem'
    },
    privateKeyEncoding: {
      type: 'pkcs8',
      format: 'pem',
      cipher: 'aes-256-cbc',
      passphrase
    }
  });

  return { publicKey, privateKey };
}

const passphrase = process.env.SIGNING_KEY_PASSPHRASE;

if (!passphrase) {
  throw new Error('SIGNING_KEY_PASSPHRASE is required');
}

const { publicKey, privateKey } = await createEd25519KeyPair(passphrase);

await writeFile('signing-key.pem', privateKey, { mode: 0o600 });
await writeFile('signing-key.pub.pem', publicKey, { mode: 0o644 });

Several details in that snippet are deliberate. The private key uses PKCS#8 encoding, which is the modern container format and the only one that works uniformly across key types. The cipher and passphrase options mean the file on disk is encrypted, so a stray backup or a misconfigured bucket does not immediately hand over your signing identity. The file mode 0o600 is set at creation time rather than with a subsequent chmod, closing the window during which the file exists with default permissions.

For RSA the call differs only in the parameters:

const { publicKey, privateKey } = await generateKeyPairAsync('rsa', {
  modulusLength: 3072,
  publicExponent: 0x10001,
  publicKeyEncoding: { type: 'spki', format: 'pem' },
  privateKeyEncoding: {
    type: 'pkcs8',
    format: 'pem',
    cipher: 'aes-256-cbc',
    passphrase
  }
});

And for ECDSA:

const { publicKey, privateKey } = await generateKeyPairAsync('ec', {
  namedCurve: 'prime256v1',
  publicKeyEncoding: { type: 'spki', format: 'pem' },
  privateKeyEncoding: {
    type: 'pkcs8',
    format: 'pem',
    cipher: 'aes-256-cbc',
    passphrase
  }
});

Note that prime256v1 is the OpenSSL name for the curve that NIST calls P-256 and that SECG calls secp256r1. They are the same curve. Node.js accepts the OpenSSL name.

Loading Keys

A PEM string can be passed directly to the signing functions, but converting it once into a KeyObject is better: the decryption and parsing happen a single time, and the resulting object does not expose the key material to JavaScript, which reduces the chance of it ending up in a log line or a heap snapshot.

import { createPrivateKey, createPublicKey } from 'node:crypto';
import { readFile } from 'node:fs/promises';

async function loadPrivateKey(path, passphrase) {
  const pem = await readFile(path, 'utf8');

  return createPrivateKey({
    key: pem,
    format: 'pem',
    passphrase
  });
}

async function loadPublicKey(path) {
  const pem = await readFile(path, 'utf8');
  return createPublicKey({ key: pem, format: 'pem' });
}

A public key can also be derived from a private key, which is convenient when you only distribute one file to signers:

const privateKey = await loadPrivateKey('signing-key.pem', passphrase);
const publicKey = createPublicKey(privateKey);

console.log(publicKey.export({ type: 'spki', format: 'pem' }));

Signing a File with Ed25519

Ed25519 and Ed448 do not use the streaming Sign class. They require the whole message at once, because the algorithm internally hashes the message twice and cannot be driven incrementally through Node's streaming interface. For these keys you use the one-shot crypto.sign and crypto.verify functions, passing null as the digest algorithm since the scheme prescribes its own.

import { sign, verify } from 'node:crypto';
import { readFile } from 'node:fs/promises';

async function signFileEd25519(filePath, privateKey) {
  const content = await readFile(filePath);
  return sign(null, content, privateKey);
}

async function verifyFileEd25519(filePath, signature, publicKey) {
  const content = await readFile(filePath);
  return verify(null, content, publicKey, signature);
}

const signature = await signFileEd25519('release.tar.gz', privateKey);

console.log(signature.toString('base64'));

const isValid = await verifyFileEd25519('release.tar.gz', signature, publicKey);

console.log(isValid ? 'signature is valid' : 'signature is invalid');

This works well up to the point where the file no longer fits comfortably in memory. Node buffers are capped, and reading a multi-gigabyte artifact into RSS to sign it is wasteful even when it succeeds. The solution is to hash the file as a stream and sign the digest instead of the content, which is covered below.

Signing with RSA-PSS and ECDSA

For RSA and ECDSA the Sign and Verify classes accept data incrementally, so a file can be streamed through them without ever being fully resident in memory.

import { createSign, createVerify, constants } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';

async function signFileStream(filePath, privateKey) {
  const signer = createSign('sha256');

  await pipeline(createReadStream(filePath), signer);

  return signer.sign({
    key: privateKey,
    padding: constants.RSA_PKCS1_PSS_PADDING,
    saltLength: constants.RSA_PSS_SALTLEN_DIGEST
  });
}

async function verifyFileStream(filePath, signature, publicKey) {
  const verifier = createVerify('sha256');

  await pipeline(createReadStream(filePath), verifier);

  return verifier.verify(
    {
      key: publicKey,
      padding: constants.RSA_PKCS1_PSS_PADDING,
      saltLength: constants.RSA_PSS_SALTLEN_DIGEST
    },
    signature
  );
}

The padding options are ignored for ECDSA keys, so the same functions work for both if you leave them in place; but being explicit is worth the noise, because omitting them on an RSA key silently falls back to PKCS#1 v1.5. That is one of the more dangerous defaults in the module: it does not fail, it just gives you weaker padding than you intended.

The saltLength value RSA_PSS_SALTLEN_DIGEST sets the salt to the digest length, which is the usual recommendation. The verifier must use a compatible setting; RSA_PSS_SALTLEN_AUTO on the verification side accepts any salt length and is a reasonable choice for interoperability.

Hash-then-Sign for Large Files

The general pattern that works for every algorithm, including Ed25519, is to compute the digest as a stream and then sign the digest bytes.

import { createHash, sign, verify } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';

async function hashFile(filePath, algorithm = 'sha256') {
  const hash = createHash(algorithm);

  await pipeline(createReadStream(filePath, { highWaterMark: 1024 * 1024 }), hash);

  return hash.digest();
}

async function signLargeFile(filePath, privateKey) {
  const digest = await hashFile(filePath, 'sha512');
  return sign(null, digest, privateKey);
}

async function verifyLargeFile(filePath, signature, publicKey) {
  const digest = await hashFile(filePath, 'sha512');
  return verify(null, digest, publicKey, signature);
}

There is a subtlety here that is easy to miss. When you sign a digest rather than a message, the digest algorithm becomes part of the protocol, and it is not recorded anywhere in the signature. If a verifier assumes SHA-256 while the signer used SHA-512, verification fails, which is annoying but safe. The unsafe variant is an attacker convincing a flexible verifier to try a weaker hash. Always record the digest algorithm alongside the signature and reject anything that is not on your allowlist.

The highWaterMark of one megabyte is a meaningful tuning knob for large files: the default 64 KB means many more round trips through the stream machinery, and raising it typically improves throughput on multi-gigabyte inputs at the cost of a slightly larger memory footprint.

Detached Signatures and a Signature Envelope

A detached signature lives in a separate file next to the artifact it covers, which is how gpg --detach-sign and most package repositories work. It keeps the artifact byte-identical to what the build produced, and it lets you add or rotate signatures without touching the payload.

Rather than writing bare signature bytes to a .sig file, it is worth defining a small envelope. The envelope carries the metadata a verifier needs, and, critically, that metadata is itself covered by the signature. Otherwise an attacker can rewrite the algorithm field or the key identifier and shift the verifier onto a path you did not intend.

import { createHash, sign, verify, createPublicKey } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { pipeline } from 'node:stream/promises';
import { basename } from 'node:path';

const SIGNATURE_VERSION = 1;
const ALLOWED_ALGORITHMS = new Set(['ed25519']);
const ALLOWED_DIGESTS = new Set(['sha256', 'sha512']);

function keyFingerprint(publicKey) {
  const der = publicKey.export({ type: 'spki', format: 'der' });
  return createHash('sha256').update(der).digest('hex').slice(0, 32);
}

function canonicalize(payload) {
  const ordered = Object.keys(payload)
    .sort()
    .reduce((accumulator, key) => {
      accumulator[key] = payload[key];
      return accumulator;
    }, {});

  return Buffer.from(JSON.stringify(ordered), 'utf8');
}

async function hashFile(filePath, algorithm) {
  const hash = createHash(algorithm);
  await pipeline(createReadStream(filePath, { highWaterMark: 1024 * 1024 }), hash);
  return hash.digest('hex');
}

async function createDetachedSignature(filePath, privateKey, options = {}) {
  const digestAlgorithm = options.digestAlgorithm ?? 'sha512';
  const publicKey = createPublicKey(privateKey);

  const payload = {
    version: SIGNATURE_VERSION,
    algorithm: 'ed25519',
    digestAlgorithm,
    digest: await hashFile(filePath, digestAlgorithm),
    fileName: basename(filePath),
    keyId: keyFingerprint(publicKey),
    signedAt: new Date().toISOString()
  };

  const signature = sign(null, canonicalize(payload), privateKey);

  return {
    ...payload,
    signature: signature.toString('base64')
  };
}

async function writeDetachedSignature(filePath, privateKey) {
  const envelope = await createDetachedSignature(filePath, privateKey);
  const target = `${filePath}.sig.json`;

  await writeFile(target, `${JSON.stringify(envelope, null, 2)}\n`, 'utf8');

  return target;
}

The canonicalization step matters more than it looks. JSON does not guarantee key order, and if the signer serializes with one order and the verifier reconstructs with another, the bytes differ and verification fails for entirely innocent files. Sorting keys before serialization gives a deterministic byte sequence. For anything beyond a simple flat object, consider a real canonical form such as JCS rather than hand-rolling one.

The verifier mirrors the process, and rejects unknown values before doing any cryptography:

async function verifyDetachedSignature(filePath, signaturePath, publicKey) {
  const envelope = JSON.parse(await readFile(signaturePath, 'utf8'));

  if (envelope.version !== SIGNATURE_VERSION) {
    throw new Error(`unsupported envelope version: ${envelope.version}`);
  }

  if (!ALLOWED_ALGORITHMS.has(envelope.algorithm)) {
    throw new Error(`unsupported algorithm: ${envelope.algorithm}`);
  }

  if (!ALLOWED_DIGESTS.has(envelope.digestAlgorithm)) {
    throw new Error(`unsupported digest: ${envelope.digestAlgorithm}`);
  }

  const expectedKeyId = keyFingerprint(publicKey);

  if (envelope.keyId !== expectedKeyId) {
    throw new Error('signature was produced by a different key');
  }

  const { signature, ...payload } = envelope;

  const signatureIsValid = verify(
    null,
    canonicalize(payload),
    publicKey,
    Buffer.from(signature, 'base64')
  );

  if (!signatureIsValid) {
    return { valid: false, reason: 'signature does not match metadata' };
  }

  const actualDigest = await hashFile(filePath, envelope.digestAlgorithm);

  if (actualDigest !== envelope.digest) {
    return { valid: false, reason: 'file content does not match signed digest' };
  }

  return { valid: true, keyId: envelope.keyId, signedAt: envelope.signedAt };
}

Two checks happen in sequence and both are necessary. The signature check proves the metadata is authentic. The digest comparison proves the file on disk is the file the metadata describes. Skipping the second one gives you a verifier that happily accepts an authentic signature attached to a completely different payload.

A Complete Command Line Tool

Putting the pieces together into something usable from a build pipeline:

#!/usr/bin/env node

import { parseArgs } from 'node:util';
import { createPrivateKey, createPublicKey } from 'node:crypto';
import { readFile } from 'node:fs/promises';

const { values, positionals } = parseArgs({
  allowPositionals: true,
  options: {
    key: { type: 'string', short: 'k' },
    signature: { type: 'string', short: 's' },
    digest: { type: 'string', short: 'd', default: 'sha512' }
  }
});

const [command, filePath] = positionals;

async function loadPrivate(path) {
  const pem = await readFile(path, 'utf8');
  const passphrase = process.env.SIGNING_KEY_PASSPHRASE;

  return createPrivateKey(passphrase ? { key: pem, passphrase } : pem);
}

async function loadPublic(path) {
  return createPublicKey(await readFile(path, 'utf8'));
}

async function main() {
  if (!command || !filePath || !values.key) {
    console.error('usage: filesign <sign|verify> <file> --key <keyfile> [--signature <sigfile>]');
    process.exitCode = 2;
    return;
  }

  if (command === 'sign') {
    const privateKey = await loadPrivate(values.key);
    const target = await writeDetachedSignature(filePath, privateKey);

    console.log(`signature written to ${target}`);
    return;
  }

  if (command === 'verify') {
    const publicKey = await loadPublic(values.key);
    const signaturePath = values.signature ?? `${filePath}.sig.json`;
    const result = await verifyDetachedSignature(filePath, signaturePath, publicKey);

    if (!result.valid) {
      console.error(`INVALID: ${result.reason}`);
      process.exitCode = 1;
      return;
    }

    console.log(`OK: signed at ${result.signedAt} by key ${result.keyId}`);
    return;
  }

  console.error(`unknown command: ${command}`);
  process.exitCode = 2;
}

await main();

The exit codes are not decoration. A verification tool used in CI must exit non-zero on failure, and it must distinguish a failed verification from a usage error, because a pipeline that treats every non-zero exit as "signature invalid" will produce confusing incident reports when someone simply typos a path.

Signing Multiple Files as a Set

Releases usually consist of several artifacts. Signing each one independently works, but it lets an attacker mix and match: take the signed binary from version 1.2 and the signed manifest from version 1.3, and both signatures verify while the combination is something you never shipped. The fix is to sign a manifest that lists every file with its digest, and to sign only that manifest.

import { createHash, sign } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { relative, resolve } from 'node:path';

async function buildManifest(rootDir, filePaths, digestAlgorithm = 'sha512') {
  const entries = [];

  for (const filePath of filePaths) {
    const absolute = resolve(rootDir, filePath);
    const hash = createHash(digestAlgorithm);

    await pipeline(createReadStream(absolute), hash);

    entries.push({
      path: relative(rootDir, absolute).split('\\').join('/'),
      digest: hash.digest('hex')
    });
  }

  entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));

  return { version: 1, digestAlgorithm, entries };
}

async function signManifest(manifest, privateKey) {
  const bytes = Buffer.from(JSON.stringify(manifest), 'utf8');

  return {
    manifest,
    signature: sign(null, bytes, privateKey).toString('base64')
  };
}

Paths are normalized to forward slashes and sorted so that a manifest built on Windows and one built on Linux produce identical bytes for identical content. Without that, the same release signed on two machines yields two different manifests, and any downstream comparison of build reproducibility becomes noise.

The Web Crypto API Alternative

Node.js also implements the standard Web Crypto API under crypto.subtle, available as a global since Node 18. It is the right choice when the same signing or verification code must run in a browser, in a service worker, or in an edge runtime that offers no Node built-ins.

const encoder = new TextEncoder();

async function generateKeyPairWebCrypto() {
  return crypto.subtle.generateKey(
    { name: 'Ed25519' },
    true,
    ['sign', 'verify']
  );
}

async function signWithWebCrypto(privateKey, data) {
  const signature = await crypto.subtle.sign({ name: 'Ed25519' }, privateKey, data);
  return Buffer.from(signature).toString('base64');
}

async function verifyWithWebCrypto(publicKey, data, signatureBase64) {
  return crypto.subtle.verify(
    { name: 'Ed25519' },
    publicKey,
    Buffer.from(signatureBase64, 'base64'),
    data
  );
}

const keyPair = await generateKeyPairWebCrypto();
const payload = encoder.encode('release-1.4.0');
const signature = await signWithWebCrypto(keyPair.privateKey, payload);

console.log(await verifyWithWebCrypto(keyPair.publicKey, payload, signature));

The tradeoffs are real. Web Crypto has no streaming digest, so large files must be hashed in chunks by hand or read entirely into memory. Its algorithm coverage is narrower than the Node module's. And key import and export go through crypto.subtle.importKey with JWK, SPKI, or PKCS#8 formats rather than accepting PEM directly, so you will be stripping PEM headers and base64-decoding by hand. For server-only code, the Node crypto module remains more ergonomic.

Key Management

The cryptography in this article is the easy part. Every real failure of a signing system in the last two decades has been a key management failure.

A private key that sits on a build server in a file readable by the CI user is functionally a key held by anyone who can push a pull request that runs arbitrary code. If your threat model includes a compromised build, the key must live somewhere the build cannot read it: a hardware token, a cloud KMS, or a dedicated signing service that accepts a digest and returns a signature after applying its own policy. Node.js can talk to all of these; the code above changes only in that sign becomes a network or PKCS#11 call.

Rotation must be designed in from the start. The keyId field in the envelope exists so that a verifier can hold several trusted public keys at once and select the right one, which is what makes it possible to introduce a new key before retiring the old one. A system that hardcodes a single public key cannot rotate without a flag day.

Revocation is the hardest piece and is often skipped. Ask what happens when a key is known to be compromised: how does a verifier that already downloaded a public key learn to stop trusting it? Answers range from a short-lived certificate to a signed revocation list to a transparency log. If the answer is "we push a new release of the verifier", accept that the response time to a compromise is the update cadence of your slowest client.

Finally, remember what a signature proves. It proves the holder of a key signed these bytes at some point. It does not prove when, unless a trusted timestamp authority countersigns; it does not prove the signer intended this particular use, unless the signed payload says so; and it does not prove the file is safe, only that it came from the key you decided to trust.

Common Mistakes

Comparing signatures with string equality. Verification must go through crypto.verify or the Verify class. Recomputing a signature and comparing it to the received one with === is wrong for ECDSA and RSA-PSS, which are randomized and produce a different valid signature each time. When you do need to compare secret-derived byte strings, such as HMAC tags, use crypto.timingSafeEqual rather than ===.

Using HMAC and calling it a signature. An HMAC requires both parties to share the same secret, which means either party can produce a tag. It gives you integrity and authenticity between two trusted parties, but no non-repudiation and no ability to publish a verification key. If the verifier is a third party, HMAC is not the tool.

Signing the file name and trusting it on verification. Including a file name in the envelope is useful for diagnostics, but a verifier that uses that name to decide where to write output has built a path traversal vulnerability signed by your own key. Treat every field in the envelope as data to be validated, not as instructions.

Forgetting PSS padding on RSA. As noted above, leaving the padding option out gives you PKCS#1 v1.5 silently.

Verifying the signature and then reading the file again. If you hash a file, verify the signature, and then open the file a second time to use it, an attacker with local access has a window to swap the contents between the two reads. In hostile environments, verify what you have in memory and use exactly those bytes.

Treating a missing signature as a pass. A verifier that skips checking when the .sig file is absent is not a verifier. Absence of a signature must be a failure, and the code path that decides this should be impossible to reach by accident.

Conclusion

Node.js gives you everything needed to sign files properly: modern algorithms through a stable built-in module, streaming digests for artifacts of any size, and a standards-based alternative when code must run in more than one runtime. Ed25519 with a hash-then-sign pipeline and a signed JSON envelope covers the overwhelming majority of use cases in a few dozen lines.

The code is the smaller half of the work. Decide where the private key lives and who can reach it, how a verifier learns which keys to trust, how you rotate, and what happens on the day a key leaks. Get those right and the cryptography will hold up. Get them wrong and no algorithm choice will save you.