Compatibility
Published
Cryptographic strength on the server is mostly a configuration decision, and configuration in Node is an object literal. The cost parameters of a key derivation function, the encoding of a generated key pair, the length of an authentication tag: all of them are properties that node reads and your code writes. Node does not reject an option object whose keys it does not recognise. It applies its documented defaults, which is the difference between this page and the equivalent one about the browser.
What was measured
One file against node's own crypto module, driven through five protection profiles for the base column and both member-renaming profiles for the rest. It derives a password hash with scrypt at a deliberately raised cost, compares it against a hash produced by an earlier release and pasted in as an opaque literal, records the parameters beside the hash, generates an RSA key pair with PEM encodings, and encrypts a record with an authenticated cipher.
The base column is clean on all five profiles: the same derived key, the same verification result, the same PEM output, the same tag length. Protecting a service that hashes passwords does not change what it computes.
The comparison against a previously stored hash is the point of the design. A file that hashes a password and then verifies it against a hash it computed moments earlier proves nothing, because both halves move together under renaming. The stored value here was produced by a different run and is treated as text, which is what makes the verification a real external contract.
Every stored password stops verifying
With a pattern matching the three scrypt cost parameters, the derived key changed completely and the verification against the stored hash went from true to false.
The mechanism is the same one this whole series keeps finding, in its most consequential form. Node received an options object with no keys it recognised, so it used its default cost parameters, which are lower than the ones this service chose. Different parameters produce a different key from the same password and salt. Nothing threw. The function returned a perfectly well-formed hash of the correct length.
In production that is a total authentication outage on deploy: every existing user's password fails, for a service whose code still shows the raised cost parameter on the line above. It also has a quieter half. If the same build creates new accounts, those hashes are stored at the default cost, so the service silently downgrades the protection of every password set after the release, and re-deriving them later at the intended cost is not possible without the plaintext.
The detail that makes it hard to audit is in the record the service writes beside each hash. That record names the algorithm and the cost parameter in fields of its own, and those fields did not move in this arm, because they are spelled differently from the option names node reads. So the stored record still claims the strong cost parameter, while the hash beside it was computed with the default. An audit that reads the parameter record and reports the service as correctly configured is reading exactly what it was designed to read.
The loud arms, and the silent one in the middle
Not all of this is quiet, and the split is useful. The memory-limit option threw a RangeError from OpenSSL on the first derivation, because the raised cost needs more memory than node's default limit allows. The key-size option threw an argument-type error naming the property. The encoding type and format keys threw an invalid-value error naming the exact path into the options object. All three fail on the first call, in development, and name what is wrong.
One arm between them is silent and is the one to remember. Renaming the two key-encoding blocks did not throw: node simply returned key objects rather than PEM strings, because the encoding request never arrived. The public key stopped being a PEM string and the private key stopped being a string at all. Code that writes that key to a file writes an object's string form; code that posts it to a service posts something the far side cannot parse. The generation succeeded, the process exited zero, and the artifact is wrong.
Reading the returned pair by renamed names produced undefined and then an argument-type error one call later, which is the ordinary container-and-leaf split this series has measured repeatedly: read a container that is missing and you throw at once, read a leaf and you continue with nothing.
An authenticated cipher that accepts a forged tag
The authentication tag length arm measured identical at first, and the reason is instructive: the value the file passed happened to equal node's default, so removing the option changed nothing. An arm that looks inert can be inert only because of the value, not because of the name, and the way to find out is to run the operation the name governs.
A second experiment written for exactly that, with a protocol that fixes the tag at twelve bytes: after renaming, the sender produced a sixteen-byte tag while the record beside the ciphertext still says twelve. Any peer that validates the length rejects the message, which is a loud interoperability failure and the better outcome.
The receiving side is the serious one. With the option gone, the decryption call no longer pins the tag length, and node then accepts a shorter tag. Verified directly against node's crypto module: a four-byte prefix of a valid sixteen-byte tag was accepted and the plaintext returned. That reduces the work of forging an acceptable message by orders of magnitude, and node's only signal is a deprecation warning on standard error about short tags, which no service reads. Pinning the length on the decryption side restored the rejection immediately.
Why this is louder in a browser than on a server
Anyone who has read the companion page on Web Crypto will notice the contrast. In the browser, an algorithm dictionary with unrecognised keys fails normalisation and throws, so a renamed key produces an immediate, named error at the first call.
Node does not normalise in that way. Unknown properties are ignored, missing properties take documented defaults, and the call succeeds. The same mistake is therefore loud in front-end code and silent in back-end code, and back-end code is where the stored password hashes are.
That asymmetry is the argument for treating server-side protection as a distinct review, rather than assuming a configuration that behaved well in a browser bundle will behave the same way in a service.
What to do about it
Protection alone was clean on every profile, so none of this argues against protecting server-side JavaScript. It argues for a narrow member pattern and two tests.
Keep node's crypto vocabulary out of the pattern: N, r, p, maxmem, modulusLength, publicKeyEncoding, privateKeyEncoding, type, format, cipher, passphrase, authTagLength, ivLength, saltLength. Single-letter names like N, r and p are the worst case for a broad pattern and the best argument for anchoring to a naming convention you own.
Test one: keep a fixture password and a hash produced by a known-good build, and assert verification against it in CI. That single assertion fails on the first arm above and would have stopped an authentication outage before deploy. Test two: assert the shape of what you generate, that the public key is a string beginning with a PEM header and that the tag is the number of bytes your protocol specifies.
And on the decryption side, always pass the tag length explicitly. It is the difference between a cipher that authenticates and one that accepts a truncated tag with a deprecation warning nobody sees.
Frequently asked questions
Does obfuscation change what a password hash computes?
Not in the default configuration. Deriving a key with scrypt, verifying it against a hash from an earlier release, generating an RSA key pair and encrypting with an authenticated cipher all produced identical results on the five protection profiles measured. It becomes a surface only when member renaming reaches the parameter names node reads.
What happens if a member pattern matches scrypt cost parameters?
Node applies its default parameters, so the derived key is different and every stored password fails verification. Measured directly: the verification against a hash produced by an earlier release went from true to false, with nothing thrown and a correctly formed hash returned.
Why does the parameter record beside the hash still look right?
Because the fields in that record are spelled differently from the option names node reads, so they were not part of the pattern. The record still names the raised cost parameter while the hash beside it was derived with node's default, which is why an audit of stored parameters cannot detect this.
Are new passwords stored at a weaker cost after this?
Yes, in the same run. Accounts created by the affected build have hashes derived at node's default cost rather than the raised one the service configured, and raising them again later is not possible without the plaintext password, so the accounts have to be re-derived at next sign-in.
Which parts of node's crypto API fail loudly?
The memory limit option raised a RangeError from OpenSSL on the first derivation, the key size option raised an argument-type error, and the encoding type and format keys raised an invalid-value error naming the path into the options object. The silent one is the key encoding block, which returns key objects instead of PEM strings.
Can renaming weaken an authenticated cipher?
Yes. With the tag length option renamed, the sender emitted a sixteen-byte tag where the protocol specifies twelve, and the receiving side stopped pinning the length. Verified directly against node: a four-byte prefix of a valid tag was accepted and the plaintext returned, with only a deprecation warning on standard error.
Why is this quieter than the same mistake in the browser?
Because Web Crypto normalises algorithm dictionaries and throws on keys it does not recognise, while node ignores unknown properties and applies documented defaults. The identical mistake is an immediate error in front-end code and a silent configuration change in a service.
Related reading