node:crypto is the cryptography module that ships with every Node.js release. Its one-shot crypto.sign() and crypto.verify() functions and the KeyObject class cover ECDSA, Ed25519, Ed448, RSA and, as of Node.js 24.22, ML-DSA, SLH-DSA and ML-KEM. Everything runs on the OpenSSL that Node.js bundles (3.5.8 in Node.js 24.22.0), so most behaviour mirrors the OpenSSL command line — and differs from the WebCrypto API that the same process exposes as crypto.subtle.
The interop bug you are most likely to hit is the signature encoding. crypto.sign() returns ASN.1 DER, while WebCrypto, JWS/JWT, .NET and most hardware wallets use the fixed-width raw r‖s form. A signature in the other encoding does not throw: crypto.verify() simply returns false, which looks exactly like a forged signature. Set dsaEncoding: "ieee-p1363" on the key object on the signing side and on the verifying side, or convert the bytes with the DER ⇄ raw converter.
Two quieter defaults matter when the other side is strict. Signatures are never normalized to low-S, so roughly half of them are rejected by verifiers that insist on it, such as libsecp256k1 and @noble/curves 2.x. And the nonce is random, so the same key and message give different bytes on every call — compare verification results in tests, never signature bytes. If one signer ever repeats a nonce, the nonce reuse checker recovers the key, which is why a random nonce needs a healthy random source.
Pass null as the algorithm and an EC key is quietly signed with SHA-256, whatever its curve; a P-384 key then fails against any peer that expects SHA-384. Name the hash in every call, load keys once with crypto.createPublicKey() or crypto.createPrivateKey() — they read PEM, DER, JWK and even the bare SEC1 point — and keep the resulting KeyObject instead of re-parsing strings.
To check a signature produced elsewhere, paste it with the key into the signature verifier; to see which defaults collide with another stack, pick both sides in the library interop matrix.