Secrets in JavaScript: the problem you can't fully solve

JavaScript strings live in the heap until the Garbage Collector decides otherwise. And when it comes to key derivation functions, Argon2 is not really a first-class citizen.

Secrets

Most security advice about storing passwords or deriving keys focuses on the algorithm — use bcrypt, use Argon2, never MD5. That’s all correct, but it addresses only part of the problem. Before any hashing happens, the password exists in memory as a value your program can read. How long it stays there, and whether you can do anything about it is a very uncomfortable question for JavaScript applications.

This is not meant to scare you away from JavaScript for security-sensitive applications. It is meant to be honest about the guarantees you can actually get, and to point you toward the best practices for getting as close to those guarantees as possible within the constraints of the platform.


Strings are not yours to clear

In languages like C or Rust, zeroing a buffer after use is a basic security hygiene step. You write the secret into memory, use it, then overwrite every byte with zeros before releasing it. The window during which an attacker with memory access could read the secret through e.g. a crash dump, a swap file, a cold-boot attack, or a process inspector, is minimized to the time it was actually needed.

JavaScript does not give you this control. Strings are immutable and managed by the Garbage Collector. Once the string my secret password exists in the V8 heap, you cannot reach in and overwrite it. You can drop all references to it, but that just makes it eligible for collection — it does not collect it. The string may stay in memory for milliseconds, or it may stay for the lifetime of the process.

let password = getUserInput(); // now in the heap
await sendToServer(password);
password = null; // reference is gone — the data is not

The assignment to null reassigns the variable. The original string object remains in the heap, untouched, until V8’s garbage collector decides to reclaim it. You have no say in when that happens.

You can drop the reference. You cannot drop the data.

Buffers: a partial answer

Uint8Array and other typed arrays are a different story. They do not represent immutable string values, but rather represent raw binary memory, which you can zero out explicitly:

const passwordBytes = new TextEncoder().encode(password);
// ... use passwordBytes ...
passwordBytes.fill(0); // actually zeros the underlying memory

This is the approach taken by libraries like noble-hashes and various cryptographic toolkits - they work exclusively with Uint8Array, zero buffers after use, and never produce intermediate strings containing sensitive material.

The string that was always there

The buffer approach is meaningful, but it has a ceiling. In almost every real application, the password began its life as a JavaScript string — typed into a TextInput, read from a form, or loaded from storage. That string existed in the heap before you encoded it into a Uint8Array. TextEncoder does not consume and destroy the source string; it reads it and produces a new buffer. You now have both in memory.

const password = await SecureStore.getItemAsync('password'); // string in heap
const passwordBytes = new TextEncoder().encode(password);    // buffer in heap
                                                             // both exist now

You can zero the buffer, but the original string is still there, uncleared, until the GC runs. This is not a reason to give up on buffers, on the contrary. The likelihood of needing the password for various operations after grabbing it from input is very high. Zeroing what you can zero is still better than zeroing nothing. They concept I’m conveying here, is that reducing your window of exposure, is better than doing nothing. With such an approach, you’re reducing significantly the risk of an attacker getting access to the password, even if you can’t eliminate it entirely.

You said ‘key derivation function’?

A key derivation function (KDF) takes a low-entropy secret like a password (e.g. password123) and produces a fixed-length output that is deliberately expensive to compute, so that brute-forcing the input from the output is impractical. And in JavaScript, Argon2 is not really a first-class citizen.

The KDF landscape

What is Argon2? Here’s a brief tour of the KDF landscape:

  • PBKDF2 applies a pseudorandom function — typically HMAC-SHA256 — a configurable number of iterations. It is fast to compute on commodity hardware. An attacker with a modern GPU can attempt hundreds of millions of guesses per second at typical iteration counts. This is becoming increasingly achievable to crack with modern hardware. The general answer is to increase the iterations, but that comes at a cost of user experience. It persists because it is standardized and compliant, not because it is strong.

  • bcrypt introduced a work factor and uses a fixed 4 KiB internal state, which adds some memory pressure. It is meaningfully better than PBKDF2 against GPU attacks, but the fixed and relatively small memory footprint means purpose-built hardware can still make significant headway. It also caps passwords at 72 bytes, which is a silent truncation hazard.

  • scrypt, designed by Colin Percival in 2009, was the first algorithm to make memory cost a primary parameter. More memory per hash means more VRAM required per parallel guess, which drives attacker hardware costs up sharply. Node.js ships crypto.scrypt natively since v10.5 — making it the strongest KDF you can reach without a third-party dependency in a Node environment, but still it is absent from crypto.subtle, and thus unavailable in browsers.

Argon2 won the Password Hashing Competition in 2015 and is the current recommendation from OWASP and RFC 9106. It comes in three variants:

  • Argon2d — data-dependent memory access, maximally resistant to GPU cracking but vulnerable to side-channel attacks.
  • Argon2i — data-independent memory access, eliminating side-channel risk at the cost of some GPU resistance.
  • Argon2id — a hybrid that uses data-independent access for the first pass and data-dependent access thereafter. It is the recommended default for password hashing in almost every context.

Its tunable parameters — memory (KiB per hash), iterations (passes over that memory), and parallelism (independent lanes) — give you direct control over the cost model. Memory cost is generally the more effective lever: a 64 MiB hash requires 64 MiB of VRAM per attempt, forcing attackers to either invest in expensive hardware or serialize their guesses.

What the platform gives you

Browser and Node.js standard library

crypto.subtle — the Web Crypto API — is the browser’s built-in cryptographic primitive layer. It is standardized, hardware-accelerated where the platform supports it, and available in modern browsers and Node.js without any dependencies.

Its key derivation support consists of two algorithms: HKDF for deriving keys from high-entropy material, and PBKDF2 for deriving keys from low-entropy secrets like passwords.

That is it. No bcrypt. No scrypt. No Argon2.

If you want to hash a password with the algorithm that actually won the Password Hashing Competition, you are on your own.

Native mobile

The native platforms do not improve the picture much. On iOS, Apple’s CryptoKit (iOS 13+) and the older CommonCrypto both provide PBKDF2 — specifically PBKDF2.deriveKey with HMAC-SHA256 — and HKDF. Nothing else for password hashing. On Android, javax.crypto.SecretKeyFactory covers PBKDF2WithHmacSHA256 and its variants. Android also ships Bouncy Castle, which includes scrypt and technically Argon2, but Bouncy Castle is an implementation detail of the Android runtime rather than a stable public API — its availability and behavior vary across versions and vendors.

expo-crypto, the Expo wrapper around platform cryptography, mirrors this: it exposes crypto.subtle semantics, which means you get PBKDF2 and HKDF and nothing beyond that.

Why Argon2 is not in the standard

The Web Crypto API is standardized by the W3C. Standardization moves slowly and conservatively, particularly for algorithms that are newer or less broadly implemented at the OS level. PBKDF2 has been in the standard since the beginning because it was already everywhere. Argon2 has no native OS-level support on most platforms, which makes hardware acceleration — a key concern for a browser API — much harder to provide.

There have been proposals to add Argon2 to crypto.subtle. None have shipped. The result is that a decade after Argon2 won the competition specifically designed to identify the best password hashing algorithm, you cannot use it through any standard JavaScript API.

What you can do about it

Browser and Node.js standard library

In the browser, crypto.subtle gives you PBKDF2 natively — nothing stronger. If you need bcrypt or scrypt in a browser context you must ship a third-party library, which is typically a WebAssembly build. These work, but they add bundle weight and run on the main thread unless you push them to a Web Worker. Argon2 in the browser means the same trade-off, plus a meaningfully larger WASM binary. However, there are generally established libraries for all of these, and the performance is acceptable for many use cases.

In Node.js the story is better: crypto.scrypt has been available since v10.5 and is a solid choice for most server-side password hashing needs. PBKDF2 is there too via crypto.pbkdf2. Argon2 requires a native addon (most commonly argon2 from npm, which wraps the reference C implementation) — straightforward in environments where you control the build, but unavailable in edge runtimes or sandboxed environments that restrict native modules.

Native mobile

The options for mobile are similar, but the constraints of the platform make the trade-offs more acute. You generally have three paths to choose from:

  1. A pure JavaScript implementation — slow, runs on the main thread. For parameters strong enough to matter, this is often not viable.
  2. A WebAssembly build — faster than pure JS, but carries WASM startup overhead and varies in availability (Expo Go does not support it).
  3. Native bindings — the fastest path, with full memory control. Only available in environments where you can ship native code.

On mobile with Expo, native bindings are the right answer, but when it comes to libraries, things are a bit thin and not well maintained.

expo-crypto-argon2: a standalone Expo Module for Argon2id hashing

inntend/expo-crypto-argon2

Argon2 bindings for Expo apps

TypeScript00MIT

I’ve attempted to fill this gap with expo-crypto-argon2. It is a standalone Expo Module that provides Argon2id hashing on iOS and Android, wrapping the reference C implementation. On iOS, Swift dispatches the work to a background queue. On Android, Kotlin calls the C code via JNI. The hashing runs natively, off the main thread, and the native layer explicitly zeros sensitive buffers in native memory before returning. You still risk the original string being in memory, but the actual hashing work is done in a way that minimizes the window of exposure for the derived key.

import { argon2id } from 'expo-crypto-argon2';

const passwordBytes = new TextEncoder().encode(password);

const hash = await argon2id({
  password: passwordBytes,
  salt: saltBytes,
  iterations: 3,
  memorySize: 65536, // 64 MiB
  parallelism: 1,
  hashLength: 32,
  outputType: 'encoded', // $argon2id$v=19$m=65536,t=3,p=1$...
});

passwordBytes.fill(0);

Both password and salt accept Uint8Array, so you can pass the buffer directly and zero it after the call.

Tip

The OWASP baseline for Argon2id on constrained environments is m=19456 (19 MiB), t=2, p=1. The preferred configuration is m=65536 (64 MiB), t=3, p=4. On mobile, test your chosen parameters on representative low-end hardware — a configuration that hashes in 200 ms on a flagship can take several seconds on a budget Android device.