tweetnacl
TweetNaCl.js is a JavaScript port of TweetNaCl, Daniel J. Bernstein's 100-tweet implementation of the NaCl crypto library. It gives you five things and nothing else: authenticated public-key encryption (nacl.box, which is X25519 plus XSalsa20-Poly1305), authenticated secret-key encryption (nacl.secretbox), Ed25519 signatures (nacl.sign), SHA-512 hashing (nacl.hash), and raw X25519 scalar multiplication. Everything takes and returns Uint8Array; there is no string handling, no key serialisation format, and no protocol layer. It has no dependencies, works identically in browsers and Node, and was audited by Cure53 in early 2017 with no security problems found. The package ships two builds and defaults to nacl-fast.js, the optimised one.
A correct, audited, public-domain implementation that earned its place before browsers had usable crypto, and it still works exactly as advertised. On any platform with WebCrypto, reach for the native APIs or the noble packages first; keep tweetnacl for XSalsa20-Poly1305 interop and for runtimes where nothing else fits.
Use it if
- You need XSalsa20-Poly1305 authenticated encryption specifically, because that is the one NaCl primitive WebCrypto does not implement and the one thing here you cannot get from the platform
- You have to interoperate with something already speaking NaCl or libsodium's crypto_box and crypto_secretbox wire format, where the exact primitive and nonce layout are fixed by the other side
- You need identical behaviour across browsers, Node, React Native, and old runtimes with no native modules, no WASM, and no build step, and you would rather have one 10 KB file than a matrix of platform code paths
- You want a small, audited, public-domain implementation you can read end to end, which is a real argument for embedded or bundled-into-a-widget contexts
- You are on a modern platform. The README itself says WebCrypto covers X25519, Ed25519, and SHA-512 and that you should use it if possible. Node's crypto and browser SubtleCrypto run those in native code, keep key material outside JavaScript heap objects, and are orders of magnitude faster than pure JS
- You want current maintenance. 1.0.3 shipped in February 2020 and there has been no release in over six years. The repository is alive but quiet, with 5 open issues, and the practical reading is that this is a finished artifact rather than an evolving library
- You need any of the sharp edges handled for you. Nonces are entirely your problem, and reusing one nonce with one key destroys the security of both messages. There is no key commitment, so a ciphertext can decrypt to different valid plaintexts under different keys. Ed25519 signatures here are malleable, so a signature is not a unique message identifier. SHA-512 as exposed is vulnerable to length extension. All four are documented, none are fixed
- You care about bundle size for one primitive. There is no exports map and no module build, so importing tweetnacl to verify a signature drags in the whole thing at roughly 10.5 KB gzipped. @noble/curves lets a bundler keep only the curve you use
- You need anything past the five primitives: sealed boxes, crypto_auth, password hashing, AES, HKDF, X448, or streaming encryption all live in third-party forks and add-on packages of varying liveness
- You are handling strings. Every function is Uint8Array in, Uint8Array out, and the companion tweetnacl-util package is a separate, equally frozen dependency. TextEncoder and base64 helpers are usually the better answer
- You need timing guarantees. The code is algorithmically constant-time, but the README is explicit that JIT compilers and garbage collection make physical constant time impossible to promise in JavaScript, and secrets cannot be reliably wiped from memory
Setup reality
npm install tweetnacl and there is nothing else to configure: no dependencies, no native build, no postinstall, and a nacl.d.ts included for TypeScript. The package.json has main pointing at nacl-fast.js and no exports map or module field, so bundlers get one CommonJS file and tree shaking does not apply. Two runtime facts matter before you write code. First, randomness comes from crypto.getRandomValues in browsers or crypto.randomBytes in Node, and on a platform that provides neither, nacl.randomBytes, nacl.box.keyPair, and nacl.sign.keyPair throw while the deterministic functions keep working; nacl.setPRNG exists to plug in your own source, and it replaces the internal generator completely. Second, Buffer objects can be passed in because they are backed by Uint8Array, but everything comes back as a plain Uint8Array, and some returns are subarrays of an internal buffer, so convert with Buffer.from(array) and never Buffer.from(array.buffer) or you will read neighbouring bytes. The remaining setup work is protocol design: generating a fresh 24-byte nonce per message, transporting it alongside the ciphertext, and deciding how keys are encoded and stored, none of which the library does for you.
Patterns
Create key pairs for encryption and for signinggenerate-keypairs
import nacl from 'tweetnacl';
const enc = nacl.box.keyPair();
// enc.publicKey 32 bytes
// enc.secretKey 32 bytes
const signing = nacl.sign.keyPair();
// signing.publicKey 32 bytes
// signing.secretKey 64 bytesBox and sign keys are different types and different lengths; do not reuse one pair for both. Both calls throw if the platform has no secure random source, so wrap them if you support exotic runtimes.
Encrypt to someone's public keypublic-key-encrypt
const nonce = nacl.randomBytes(nacl.box.nonceLength); // 24 bytes
const message = new TextEncoder().encode('hello');
const ciphertext = nacl.box(
message, nonce, theirPublicKey, mySecretKey,
);
// ciphertext is message.length + nacl.box.overheadLength (16) bytes
const plaintext = nacl.box.open(
ciphertext, nonce, myPublicKey, theirSecretKey,
);box.open returns null on any authentication failure rather than throwing, so an if (!plaintext) check is mandatory. Treating null as an empty message is the classic way to turn a tamper detection into a silent accept.
Ship the nonce with the ciphertexttransport-the-nonce
function seal(message, theirPub, mySec) {
const nonce = nacl.randomBytes(nacl.box.nonceLength);
const box = nacl.box(message, nonce, theirPub, mySec);
const out = new Uint8Array(nonce.length + box.length);
out.set(nonce);
out.set(box, nonce.length);
return out;
}
function unseal(payload, theirPub, mySec) {
const nonce = payload.slice(0, nacl.box.nonceLength);
const box = payload.slice(nacl.box.nonceLength);
return nacl.box.open(box, nonce, theirPub, mySec);
}Nonces are not secret, only unique. A fresh random 24-byte nonce per message is safe; a counter is safe only if you can guarantee it never resets or forks. Reusing a nonce under the same key pair leaks the relationship between both plaintexts.
Encrypt with a shared symmetric keysecret-key-encrypt
const key = nacl.randomBytes(nacl.secretbox.keyLength); // 32
const nonce = nacl.randomBytes(nacl.secretbox.nonceLength); // 24
const box = nacl.secretbox(message, nonce, key);
const opened = nacl.secretbox.open(box, nonce, key);secretbox is XSalsa20-Poly1305, the one primitive here that WebCrypto still does not offer. It is not key-committing: a crafted ciphertext can open to valid plaintexts under two different keys, which matters if you ever try keys in a loop to identify a recipient.
Reuse the Diffie-Hellman result across many messagesprecompute-shared-key
const shared = nacl.box.before(theirPublicKey, mySecretKey);
const box1 = nacl.box.after(msg1, nonce1, shared);
const box2 = nacl.box.after(msg2, nonce2, shared);
const back = nacl.box.open.after(box1, nonce1, shared);box.before does the expensive X25519 step once. In a chat or session loop this is the difference between a few hundred and tens of thousands of operations per second, since scalarMult dominates the cost of box.
Sign and verify without copying the messagedetached-signatures
const signature = nacl.sign.detached(message, signing.secretKey); // 64 bytes
const ok = nacl.sign.detached.verify(
message, signature, signing.publicKey,
);
if (!ok) throw new Error('bad signature');Prefer detached over nacl.sign, which returns signature and message concatenated and forces you to copy the payload twice. Ed25519 here is malleable: a third party can derive a different valid signature for the same message, so never use a signature as a deduplication key.
Use the combined form when the format demands itcombined-signatures
const signed = nacl.sign(message, secretKey); // 64-byte sig + message
const message2 = nacl.sign.open(signed, publicKey); // null if invalidsign.open returns null rather than throwing on a bad signature, the same trap as box.open. This form exists for NaCl wire compatibility; new protocols should use the detached functions.
Derive a signing key pair from a stored 32-byte seeddeterministic-keys-from-seed
const seed = loadSecret(); // 32 bytes of real entropy, not a password
const pair = nacl.sign.keyPair.fromSeed(seed);
// Rebuild from a saved 64-byte secret key instead:
const same = nacl.sign.keyPair.fromSecretKey(pair.secretKey);The README warns against fromSeed for general use because the seed must carry full entropy. Feeding it a password or a hash of one gives you a key only as strong as the password, with no stretching in between.
Hash with SHA-512hash-bytes
const digest = nacl.hash(new TextEncoder().encode('message'));
console.log(digest.length); // 64Plain SHA-512, which means length-extension attacks apply: never build a MAC as hash(secret || message). Use nacl.secretbox for authenticated encryption or an HMAC from WebCrypto if you need a keyed digest.
Compare secrets without a timing leakconstant-time-compare
const equal = nacl.verify(tokenFromRequest, expectedToken);
if (!equal) reject();Returns false for zero-length inputs and for length mismatches, and only then compares contents. Never use === or a loop with an early return on secret material; the comparison time tells an attacker how many bytes they guessed right.
Convert between strings, base64, and Uint8Arrayencode-strings-and-keys
// text
const bytes = new TextEncoder().encode('hello');
const text = new TextDecoder().decode(bytes);
// base64 for transport, Node
const b64 = Buffer.from(publicKey).toString('base64');
const key = new Uint8Array(Buffer.from(b64, 'base64'));Use Buffer.from(uint8array), which copies. Buffer.from(uint8array.buffer) shares memory and, because some tweetnacl returns are subarrays of a larger internal buffer, hands you bytes that are not part of your value.
Supply your own PRNG on a platform without onecustom-random-source
nacl.setPRNG((x, n) => {
const bytes = myHardwareEntropy(n); // must be cryptographic
for (let i = 0; i < n; i++) x[i] = bytes[i];
});This replaces the internal generator entirely, for the whole process. It is an escape hatch for exotic runtimes only; anything derived from Math.random or a timestamp makes every key the library generates predictable.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @noble/curves | npm | You want audited, actively maintained, tree-shakeable Ed25519 and X25519 with modern TypeScript types. |
| @noble/ciphers | npm | You need XSalsa20-Poly1305 or ChaCha20-Poly1305 from a package that is still shipping releases. |
| libsodium-wrappers | npm | You want the full libsodium surface, including sealed boxes, Argon2, and generichash, and can accept a WASM payload. |
| tweetnacl-util | npm | You are staying on tweetnacl and just need the string and base64 conversion helpers it deliberately omits. |