Skip to content
ecdsa.com

Ed25519 sign & verify · WebCrypto

How to sign and verify Ed25519 signatures in WebCrypto

WebCrypto signs and verifies Ed25519 signatures natively: crypto.subtle.sign with { name: "Ed25519" } works in Chrome 137+, Firefox 129+, Safari 17+ and Node.js. Keys import from PKCS#8 and SPKI bytes, the algorithm takes no hash parameter, and the RFC 8032 signature is deterministic, so the browser output matches every other language.

Tested against the WebCrypto API in WebCrypto API Node.js 24.22.0 (globalThis.crypto.subtle, the interface browsers implement) — the same crypto.subtle interface browsers implement.

PEM → bytes and bytes → hex helpers
// WebCrypto imports binary keys: strip the PEM armour and decode the base64 body
const pemToBytes = (pem) =>
Uint8Array.from(atob(pem.replace(/-----[^-]+-----|\s+/g, "")), (c) => c.charCodeAt(0));
const toHex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
sign with a PKCS#8 key, verify with an SPKI key
async function signEd25519(privateKeyPem, message) {
const key = await crypto.subtle.importKey(
"pkcs8", pemToBytes(privateKeyPem), { name: "Ed25519" }, false, ["sign"],
);
// no hash parameter: Ed25519 hashes internally
return new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, key, new TextEncoder().encode(message)));
}
async function verifyEd25519(publicKeyPem, message, signature) {
const key = await crypto.subtle.importKey(
"spki", pemToBytes(publicKeyPem), { name: "Ed25519" }, false, ["verify"],
);
return crypto.subtle.verify({ name: "Ed25519" }, key, signature, new TextEncoder().encode(message));
}
import a bare 32-byte public key
// a bare 32-byte public key (JWK "x", libsodium, Solana) imports as "raw"
const rawKey = await crypto.subtle.importKey("raw", rawPublicKey, { name: "Ed25519" }, true, ["verify"]);
console.log("raw key valid:", await crypto.subtle.verify({ name: "Ed25519" }, rawKey, signature, new TextEncoder().encode("hello ed25519")));

How it works

  1. importKey("pkcs8", …) takes the DER bytes under a BEGIN PRIVATE KEY block, so the helper strips the armour and decodes the base64 body; "spki" does the same for BEGIN PUBLIC KEY.
  2. The algorithm object is { name: "Ed25519" } in importKey, sign and verify alike. There is no hash member: SHA-512 is part of Ed25519.
  3. sign resolves to an ArrayBuffer of 64 bytes; verify resolves to true or false.
  4. A public key that arrives as 32 raw bytes (a JWK x, libsodium, Solana) imports directly with the "raw" format.

Gotchas

  • Private keys cannot be imported as "raw": a 32-byte seed with format "raw" and usage "sign" is rejected (SyntaxError: Unsupported key usage for Ed25519 key in Node.js). Use PKCS#8, or a JWK { kty: "OKP", crv: "Ed25519", d, x }.
  • Older browsers reject the algorithm name with NotSupportedError (Chrome before 137, Firefox before 129). Feature-detect with a try around importKey and fall back to a library such as @noble/curves.
  • Sign bytes, not strings: new TextEncoder().encode(text) gives the UTF-8 bytes other languages read from a file. A hex or base64 string signed as text is a different message.
  • The keys here are imported as non-extractable (false). Pass true if you need exportKey later, for example to publish the public key as a JWK.

Related recipes