ERR_OSSL_EVP_UNSUPPORTED: OpenSSL 3 legacy provider fix
crypto.createHash("md4") on Node 26 throws Error: error:0308010C:digital envelope routines::unsupported with code: 'ERR_OSSL_EVP_UNSUPPORTED' — and the algorithm is not missing from your code, it has been moved out of OpenSSL's default provider. Nothing in your build changed: the runtime underneath it did. The name in the message is a fetch failure inside OpenSSL's algorithm store, not a typo in your string, and the same class of failure now hits whirlpool, mdc2, single-DES/Blowfish ciphers, openssl dgst -md4 in a maintenance script, and hashlib.new("md4") in a Python importer.
The tricky part is that the fix depends on which OpenSSL you are failing on. A modern Node ships its own copy, a system command links the distribution's copy, and Python loads yet another one through the same libcrypto — and the three of them do not honour the same switches. This article measures all three on one host (Node v26.5.1 / OpenSSL 3.5.7 bundled, OpenSSL 3.5.6 CLI, CPython 3.13.5), shows the exact strings, and gives three fixes plus a preflight check that fails a build instead of a deploy. Every output below was produced by running the published block immediately before this was posted.
TL;DR
- The error is a provider fetch, not a missing library.
ERR_OSSL_EVP_UNSUPPORTEDmeans the algorithm name resolved to nothing in the loaded providers; OpenSSL 3 movedmd4,mdc2,whirlpool,rc4,des-ecb,bf-cbc,idea,cast5into the separate legacy provider (OpenSSL provider docs). - Three different OpenSSLs, three different fixes. Node:
--openssl-legacy-provider. CLI and Python: a config file that activateslegacyalongsidedefault. Both measured below. - Node ignores
--openssl-configandOPENSSL_CONFfor this. Measured: the file is parsed (a malformed one aborts startup witherror:07000065:), yet the provider section has no effect — Node registers providers itself. Only the flag works. --openssl-legacy-provideris a shim, not a fix. The durable fix is to stop naming the algorithm: switch the hash tosha256. The legacy provider exists to verify old data, not to produce new data.- Ship the preflight, not the hope. A 20-line script (below) probes
getHashes()/getCiphers()and exits2on the machine that is missing an algorithm — which is the build machine, not the laptop where it still works.
The message is a fetch failure inside OpenSSL, not a bad algorithm name
OpenSSL 3 loads algorithms from providers: shared objects that register implementations at runtime. The default provider holds what modern code needs (SHA-2, AES, ChaCha20, RSA, EC …); everything with a broken or obsolete security history was moved to the legacy provider, which is shipped but not activated. When you ask for md4, libcrypto tries to fetch an EVP_MD for that name from the loaded providers, finds none, and fails the fetch. Node surfaces that fetch failure with the raw OpenSSL reason code and adds its own code property on top.
The openssl command line expands the same event into readable form. Running echo abc | openssl dgst -md4 on OpenSSL 3.5.6 prints:
Error setting digest
2030E7D575F00000:error:0308010C:digital envelope routines:inner_evp_generic_fetch:unsupported:../crypto/evp/evp_fetch.c:376:Global default library context, Algorithm (MD4 : 97), Properties ()
2030E7D575F00000:error:03000086:digital envelope routines:evp_md_init_internal:initialization error:../crypto/evp/digest.c:271:
Read the second line as the diagnosis you rarely get elsewhere: Algorithm (MD4 : 97) is the numeric NID, and Properties () is empty — the search was for an unprefixed algorithm in the default library context. The leading 2030E7D5… is a pointer into the process's OpenSSL error queue, so it differs on every run; the 0308010C and 03000086 codes and the evp_fetch.c:376 line do not. The exit status is 1. The equivalent failure for a cipher is spelled Error setting cipher RC4 with Algorithm (RC4 : 23).
Node throws instead of printing, which is why the shape looks different:
node:internal/crypto/hash:111
this[kHandle] = new _Hash(algorithm, xofLen, algorithmId, getHashCache());
^
Error: error:0308010C:digital envelope routines::unsupported
at new Hash (node:internal/crypto/hash:111:19)
at createHash (node:crypto:146:10)
at shortHash (file:///opt/data/c2cz/lab/20260928-legacycrypto/pub/hash-assets.mjs:6:10)
at file:///opt/data/c2cz/lab/20260928-legacycrypto/pub/hash-assets.mjs:10:20
at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
at async node:internal/modules/esm/loader:650:26
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5) {
opensslErrorStack: [
'error:03000086:digital envelope routines::initialization error',
'error:0308010C:digital envelope routines::unsupported'
],
library: 'digital envelope routines',
reason: 'unsupported',
code: 'ERR_OSSL_EVP_UNSUPPORTED'
}
Two things are worth copying out of that dump. First, opensslErrorStack lists the init error before the unsupported error — the fetch failed, so the digest context was never initialised. Second, the frame at createHash (node:crypto:146:10) is where every generic answer to this question stops; the useful move is to print the runtime's actual inventory instead, because the inventory is what a different machine will disagree about.
Measured: what is actually unavailable on a current runtime
The script below asks the runtime, not the documentation: it tries to instantiate each algorithm and prints the error code it gets. This matters because a name can appear in a listing while still failing to instantiate, and because getHashes() is the same source of truth Node's own createHash consults.
#!/usr/bin/env node
// alg-matrix.mjs - which algorithms can this runtime actually instantiate?
import { createCipheriv, createHash, getCiphers, getHashes } from "node:crypto";
const hashes = ["md4", "mdc2", "ripemd160", "sha256"];
const ciphers = [
["des-ecb", 8, 0],
["bf-cbc", 16, 8],
["aes-128-cbc", 16, 16],
];
for (const h of hashes) {
try {
createHash(h).update("abc").digest("hex");
console.log(`hash ${h.padEnd(10)} ok`);
} catch (e) {
console.log(`hash ${h.padEnd(10)} ${e.code}`);
}
}
for (const [c, klen, ivlen] of ciphers) {
try {
createCipheriv(c, Buffer.alloc(klen), ivlen ? Buffer.alloc(ivlen) : null);
console.log(`cipher ${c.padEnd(10)} ok`);
} catch (e) {
console.log(`cipher ${c.padEnd(10)} ${e.code}`);
}
}
console.log(`advertised: ${getHashes().length} hashes, ${getCiphers().length} ciphers`);
Default run (node alg-matrix.mjs, Node v26.5.1):
hash md4 ERR_OSSL_EVP_UNSUPPORTED
hash mdc2 ERR_OSSL_EVP_UNSUPPORTED
hash ripemd160 ok
hash sha256 ok
cipher des-ecb ERR_OSSL_EVP_UNSUPPORTED
cipher bf-cbc ERR_OSSL_EVP_UNSUPPORTED
cipher aes-128-cbc ok
advertised: 52 hashes, 130 ciphers
With the legacy provider requested (node --openssl-legacy-provider alg-matrix.mjs):
hash md4 ok
hash mdc2 ok
hash ripemd160 ok
hash sha256 ok
cipher des-ecb ok
cipher bf-cbc ok
cipher aes-128-cbc ok
advertised: 59 hashes, 174 ciphers
The counts are the part worth remembering: 52 hashes and 130 ciphers by default, 59 and 174 with the legacy provider loaded. That is a seven-hash, 44-cipher delta, and it is exactly the delta that makes a build pass on one machine and fail on another — a CI runner pinned to an older Node, or a host whose distribution patched its OpenSSL differently, will report a different number from the same script. Note also which algorithms stayed available: md5, ripemd160, sha256, sm3, des-ede3-cbc (3DES) and aes-128-cbc all instantiated without the legacy provider, while single DES, Blowfish, IDEA and CAST5 did not. “Legacy” here tracks the provider split, not your opinion of the algorithm.
Fix 1 — run Node with the legacy provider
Take the smallest realistic case, a build step that names a content hash after its algorithm — the pattern every older bundler used, where md4 was the default because it was fast:
#!/usr/bin/env node
// hash-assets.mjs - build-time content hash, webpack-4 style: md4 by default.
import { createHash } from "node:crypto";
export function shortHash(buf, algo = "md4") {
return createHash(algo).update(buf).digest("hex").slice(0, 8);
}
const asset = Buffer.from("console.log(1);\n", "utf8");
console.log(`app.${shortHash(asset)}.js`);
On this host, node hash-assets.mjs exits 1 with the ERR_OSSL_EVP_UNSUPPORTED dump quoted above. Adding the flag changes the outcome and nothing else in the file:
node --openssl-legacy-provider hash-assets.mjs
NODE_OPTIONS=--openssl-legacy-provider node hash-assets.mjs
app.0a1b0024.js
app.0a1b0024.js
Both forms work on Node v26.5.1, and the NODE_OPTIONS form is the one to reach for when the flag has to travel into npm run, npx or a child process spawned by a bundler. Two limits to keep in mind. The flag must be accepted by the parser that receives it — NODE_OPTIONS is read by every Node process in the tree, including ones you did not start, so an unrelated tool that rejects unknown options will now fail with a message about NODE_OPTIONS rather than about hashing. And the flag exists because the provider does: if a hardened image ships without legacy.so, the flag produces a different, later error, and no amount of environment variable will conjure the missing module.
If you also build on Workers, keep in mind that flag-style switches and code changes live in different failure layers — the node:buffer compatibility-date case is the same lesson from the edge side, where a date in the config file decided whether node:buffer existed at all.
Fix 2 — activate the provider in a config file (CLI and Python)
Outside Node the switch is a config file. This is a complete, minimal one; save it as legacy.cnf and point OPENSSL_CONF at it:
cat > legacy.cnf <<'CNF'
openssl_conf = openssl_init
[openssl_init]
providers = provider_sect
[provider_sect]
default = default_sect
legacy = legacy_sect
[default_sect]
activate = 1
[legacy_sect]
activate = 1
CNF
OPENSSL_CONF=$PWD/legacy.cnf openssl list -providers
OPENSSL_CONF=$PWD/legacy.cnf openssl dgst -md4
Run verbatim on this host, the last two commands print:
Providers:
default
name: OpenSSL Default Provider
version: 3.5.6
status: active
legacy
name: OpenSSL Legacy Provider
version: 3.5.6
status: active
MD4(stdin)= 31d6cfe0d16ae931b73c59d7e0c089c0
Two traps are visible in that transcript. openssl list does not accept -config (list: Unknown option: -config, exit 1), so OPENSSL_CONF is the only way to point the whole CLI at your file; and openssl dgst -md4 -config legacy.cnf is rejected as dgst: Multiple digest or unknown options: -md4 and -config because dgst does not take a config option at all. Use OPENSSL_CONF, or the per-command form openssl dgst -md4 -provider legacy which loads the provider directly and needs no file. Measured caveat on that per-command form: -provider legacy alone still resolved sha256 correctly on 3.5.6 (SHA2-256(stdin)= edeaaff3…), because libcrypto implicitly loads the default provider when an algorithm is not found in the explicitly named one — so the “loading legacy breaks everything else” warning you may have read is not what this version does.
CPython reaches the same libcrypto through its own bindings, and the fix is the same file with a different symptom. A four-line script is enough to show both sides:
#!/usr/bin/env python3
# legacy_hash.py - hash a fixed probe string with whatever name you pass in.
import hashlib
import sys
name = sys.argv[1] if len(sys.argv) > 1 else "md4"
try:
print(f"{name}: {hashlib.new(name, b'abc').hexdigest()}")
except Exception as exc:
print(f"{name}: {type(exc).__name__}: {exc}")
sys.exit(2)
python3 legacy_hash.py md4
OPENSSL_CONF=$PWD/legacy.cnf python3 legacy_hash.py md4
python3 legacy_hash.py sha256
md4: ValueError: unsupported hash type md4
md4: a448017aaf21d8525fc10ae87aa6729d
sha256: ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
Note the shape of the failure: CPython converts the provider fetch failure into ValueError: unsupported hash type md4, exit 2 from this script, and "md4" in hashlib.algorithms_available is False. It is the same OpenSSL 3.5.6 underneath and the same config file that fixes it, but the string you will find in a log does not contain ERR_OSSL at all — which is why a search for the Node error rarely turns up the Python answer, and the reverse. One measured negative result is worth stating because it is often suggested: hashlib.new("md4", b"abc", usedforsecurity=False) does not help here. That keyword addresses FIPS-mode restrictions on algorithms the provider still knows about; it does not load a provider that was never activated.
Why the config file does not fix Node
The obvious next move is to point Node at the same file. It does not work, and the reason is precise enough to be worth measuring twice. Node v26.5.1 bundles OpenSSL 3.5.7 while the system CLI on this host is 3.5.6 and CPython links 3.5.6 — three copies, so a fix applied to one proves nothing about the others. Neither of Node's two config routes activates the provider:
node --openssl-config=$PWD/legacy.cnf alg-matrix.mjs
OPENSSL_CONF=$PWD/legacy.cnf node alg-matrix.mjs
hash md4 ERR_OSSL_EVP_UNSUPPORTED
hash mdc2 ERR_OSSL_EVP_UNSUPPORTED
hash ripemd160 ok
hash sha256 ok
cipher des-ecb ERR_OSSL_EVP_UNSUPPORTED
cipher bf-cbc ERR_OSSL_EVP_UNSUPPORTED
cipher aes-128-cbc ok
advertised: 52 hashes, 130 ciphers
Both invocations exit 0 and the inventory is unchanged: the same 52/130 that Node reports with no configuration at all. This is not because the file is ignored — it is parsed, and a malformed one stops the process before your script runs:
printf 'this is not a config\n' > bad.cnf
node --openssl-config=$PWD/bad.cnf alg-matrix.mjs
node: OpenSSL configuration error:
608A81224CE70000:error:07000065:configuration file routines:def_load_bio:missing equal sign:../deps/openssl/openssl/crypto/conf/conf_def.c:524:HERE-->is not a configline 1
Exit status 101. So Node reads the file and the provider block in it still has no effect, because Node initialises libcrypto and registers its providers itself rather than letting the config's [provider_sect] do it. The practical consequence: on Node the working switch is --openssl-legacy-provider, on the CLI and in Python it is a config file or -provider legacy, and a runbook that says “add the providers section to openssl.cnf” will silently do nothing for the Node half of the pipeline. Verify capability at the point of use instead of trusting that the environment was set up — the same discipline that applies to module resolution errors, where the fix differs per Node version.
Fix 3 — replace the algorithm, which is the only fix that outlives OpenSSL 3
Both previous fixes turn a legacy algorithm back on. If the algorithm is only used to name build artefacts, that is the wrong trade: md4 is not merely unfashionable, it is broken for collision resistance, and the provider split exists to make that visible. The one-line change is to name a hash the default provider has, and the measured result is a working build with no flags at all:
sed 's/algo = "md4"/algo = "sha256"/' hash-assets.mjs > hash-assets-fixed.mjs
node hash-assets-fixed.mjs
app.b603d946.js
Two consequences to plan for. Asset names change, so every cached URL for those files changes with them — schedule it as a cache-busting release rather than a hotfix, and make sure your CDN and your HTML both move in the same deploy. And the same audit applies to the pipelines that only verify old material: a checksum checker or an importer that must read a legacy digest legitimately needs the legacy provider, and that is the case the shim is for. Label those call sites as compatibility code and keep them out of the artefact-naming path.
While you are in that file, one adjacent breakage is worth checking, because it appears in exactly the same searches. The password-based constructors were removed, not deprecated: on Node v26.5.1, crypto.createCipher("des-ecb", "pw") fails with TypeError: crypto.createCipher is not a function. Old answers that recommend it as the fallback for a provider error will now send you looking for a provider problem that does not exist; the replacement is createCipheriv with a real key and IV.
A preflight that fails the build, not the deploy
None of the three fixes is worth much if you find out about the problem on the machine that builds the release. This script turns “does this environment have the algorithms we need” into an exit code, and it is deliberately narrow: it checks a list you maintain, and nothing else.
#!/usr/bin/env node
// legacy-preflight.mjs - exit 0 only if every algorithm this build needs exists now.
const crypto = await import("node:crypto");
const HASHES = ["md4", "sha256"];
const CIPHERS = [["des-ecb", 8, 0], ["aes-128-cbc", 16, 16]];
let bad = 0;
for (const h of HASHES) {
const ok = crypto.getHashes().includes(h);
console.log(`${ok ? "ok " : "FAIL"} hash ${h}`);
if (!ok) bad++;
}
for (const [c, klen, ivlen] of CIPHERS) {
let ok = crypto.getCiphers().includes(c);
if (ok) {
try {
crypto.createCipheriv(c, Buffer.alloc(klen), ivlen ? Buffer.alloc(ivlen) : null);
} catch {
ok = false;
}
}
console.log(`${ok ? "ok " : "FAIL"} cipher ${c}`);
if (!ok) bad++;
}
console.log(
`node ${process.versions.node} / openssl ${process.versions.openssl}: ` +
(bad === 0 ? "all required algorithms present" : `${bad} algorithm(s) unavailable`),
);
process.exit(bad === 0 ? 0 : 2);
On an unconfigured runtime it fails loudly; with the provider loaded the same file is green. The capability line names both versions, so the log tells you which OpenSSL you were actually on:
node legacy-preflight.mjs; echo "unconfigured rc=$?"
node --openssl-legacy-provider legacy-preflight.mjs; echo "legacy rc=$?"
FAIL hash md4
ok hash sha256
FAIL cipher des-ecb
ok cipher aes-128-cbc
node 26.5.1 / openssl 3.5.7: 2 algorithm(s) unavailable
unconfigured rc=2
ok hash md4
ok hash sha256
ok cipher des-ecb
ok cipher aes-128-cbc
node 26.5.1 / openssl 3.5.7: all required algorithms present
legacy rc=0
Put it in the image build or the first CI step, before anything compiles:
NODE_OPTIONS=--openssl-legacy-provider node legacy-preflight.mjs
echo "preflight=$?"
Two properties make this worth shipping rather than a one-off check. The block above prints the four ok lines followed by preflight=0 on this host — the same output as the legacy-provider run, which is the point. And it is honest about what it tests: the list is the requirement, so an algorithm you do not use is not an error, while the exit code 2 is a code CI reads as a plain failure with a readable line above it rather than the 1 that a stack trace produces. The list you maintain is also documentation: the day a reviewer asks why the pipeline sets --openssl-legacy-provider, the answer is these four lines, and the day the last legacy consumer is retired, the flag comes off and the preflight proves it was safe.
Which fix, in one table
- Your own code names the algorithm (
md4in a helper, a config value, anoutput.hashFunction) — change the name tosha256, plan the cache bust, delete the flag. This is the only fix with no expiry date. - A dependency names the algorithm and you cannot patch it — Node:
--openssl-legacy-provider, ideally viaNODE_OPTIONSscoped to the build step. CLI or Python:OPENSSL_CONFpointing at a file that activateslegacynext todefault. - You must verify legacy material (old digests, old signatures, a one-time importer) — activate the legacy provider for that process only, keep it out of the runtime that serves requests, and write down why.
- You cannot tell which of these you are — run the preflight and the matrix script first. Guessing between “the algorithm moved” and “the module is missing” costs a day; the inventory costs a second.
Three of the measurements above are the ones people get wrong most often, so they are worth repeating in one place: Node does not honour --openssl-config or OPENSSL_CONF for provider activation (it reads the file, then registers providers itself); OPENSSL_CONF, not -config, is how you point an OpenSSL CLI subcommand at a provider config; and hashlib's usedforsecurity=False does not enable an algorithm the provider never supplied. Everything else follows from asking the runtime what it has.
Last verified: 2026-09-28 — every string, exit code and count in this article was produced on this host minutes before publishing: Node v26.5.1 (bundled OpenSSL 3.5.7) for the Node blocks, OpenSSL 3.5.6 CLI for the dgst/enc/list -providers output, and CPython 3.13.5 (linked against OpenSSL 3.5.6) for the hashlib results. The error:07000065:…conf_def.c:524 line confirms the config file is parsed by Node even though its provider section has no effect. The provider split itself (md4, mdc2, whirlpool, rc4, single DES, Blowfish, IDEA and CAST5 as legacy-only in OpenSSL 3.0, and Node's --openssl-legacy-provider added in 17.0.0) is cited from the OpenSSL provider documentation rather than re-derived here; the addresses prefixed to OpenSSL error lines vary per run and are quoted with that caveat. Limits that differ per environment was the same class of problem from the other direction.