Skip to content
ecdsa.com

Ed25519 sign & verify · Node.js

How to sign and verify Ed25519 signatures in Node.js

Node.js signs and verifies Ed25519 signatures with the built-in node:crypto module — no packages needed. The one-shot crypto.sign and crypto.verify functions take null where ECDSA takes a hash name, because Ed25519 (RFC 8032) hashes the message itself, and the 64-byte signature is identical on every run.

Tested with Node.js 24.22.0 (node:crypto, OpenSSL 3.5.8).

sign.mjs — sign message.txt with a PKCS#8 Ed25519 key
import { readFileSync, writeFileSync } from "node:fs";
import { createPrivateKey, sign } from "node:crypto";
const privateKey = createPrivateKey(readFileSync("key.pem")); // PKCS#8 "BEGIN PRIVATE KEY"
const message = readFileSync("message.txt");
// Ed25519 hashes the message itself (SHA-512 inside the algorithm): the digest argument is null
const signature = sign(null, message, privateKey);
writeFileSync("sig.bin", signature);
console.log("signature:", signature.toString("hex")); // 64 bytes, the same on every run
verify.mjs — check the signature against the SPKI public key
import { readFileSync } from "node:fs";
import { createPublicKey, verify } from "node:crypto";
const publicKey = createPublicKey(readFileSync("pub.pem")); // SPKI "BEGIN PUBLIC KEY"
const message = readFileSync("message.txt");
const signature = readFileSync("sig.bin");
console.log("valid:", verify(null, message, publicKey, signature));
console.log("tampered message valid:", verify(null, Buffer.from("hello ed25520"), publicKey, signature));

How it works

  1. createPrivateKey reads the BEGIN PRIVATE KEY (PKCS#8) block and createPublicKey the BEGIN PUBLIC KEY (SPKI) block. The key type comes from the OID inside the PEM, so privateKey.asymmetricKeyType is "ed25519" without any option.
  2. sign(null, message, privateKey) returns a 64-byte Buffer. Pass the message bytes themselves: SHA-512 runs inside the Ed25519 algorithm.
  3. verify(null, message, publicKey, signature) returns true or false. It throws only for unusable keys or arguments, never for a wrong signature.
  4. The run prints the same signature as every other recipe of this task for the RFC 8032 test key: Ed25519 has no random nonce, so the same key and message always give the same bytes.

Gotchas

  • Any hash name fails: sign("sha256", …) and even sign("sha512", …) throw ERR_OSSL_INVALID_DIGEST (error:1C80007A:Provider routines::invalid digest). The algorithm argument must be null or undefined.
  • The streaming createSign / createVerify classes do not support Ed25519 and throw ERR_CRYPTO_UNSUPPORTED_OPERATION. Ed25519 is one-shot: read the whole message, then call sign.
  • A key that arrives as 32 raw bytes (libsodium, Solana, a JWK x) is not PEM. Import it as a JWK — createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x }, format: "jwk" }) — instead of assembling DER by hand.
  • Comparing signature bytes in tests works here because Node.js signs deterministically, but not every implementation does: Apple CryptoKit randomizes Ed25519 signatures. Cross-platform tests should verify, not compare.

Related recipes