tweetnacl review
tweetnacl 1.0.3 is a JavaScript port of the small NaCl cryptographic API. It accepts `Uint8Array` values and exposes XSalsa20-Poly1305 secret boxes, X25519 plus XSalsa20-Poly1305 public-key boxes, Ed25519 signatures, X25519 scalar multiplication, SHA-512, random bytes, and byte comparison. The npm entry loads the faster implementation. Version 1.0.3 is a 2020 security fix for incorrect signature generation caused by carry calculation with integers beyond 32-bit bitwise behavior; verification and the encryption APIs were not affected by that bug.
tweetnacl 1.0.3 installed in 0.6 seconds as 1 dependency-free package, occupied 1 MB, bundled to 10.8 KB gzipped in our browser test, and returned 0 audit findings. Install it for an existing NaCl-compatible protocol; for new designs, prefer Web Crypto, Sodium, or JOSE so primitive selection and wire rules are not yours to invent.
We installed it
| Install | ✓ · 0.6s | 1 package on disk · 1 MB |
| Import | ✓ | ESM import works · require() works · CommonJS package |
| Browser | 10.8 KB | gzipped (32.7 KB minified), bundled with esbuild |
| Types | ✓ | TypeScript types bundled |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does tweetnacl install cleanly?
Yes. In a fresh container with an empty cache, npm install tweetnacl finished in 0.6s, leaving 1 package and 1 MB on disk. npm audit reported no known vulnerabilities.
How much does tweetnacl add to a browser bundle?
10.8 KB gzipped (32.7 KB minified) when the whole package is bundled for the browser with esbuild. Importing only part of it is usually smaller.
Does tweetnacl work with both ESM and CommonJS?
Yes. Both import 'tweetnacl' and require('tweetnacl') worked in Node 22 in our run. The package is published as CommonJS.
Does tweetnacl include TypeScript types?
Yes, type declarations ship inside the package, so no @types install is needed.
tweetnacl or libsodium-wrappers: which should you use?
libsodium-wrappers: Use it for a wider maintained Sodium API with password hashing, sealed boxes, and additional modern constructions. tweetnacl 1.0.3 installed in 0.6 seconds as 1 dependency-free package, occupied 1 MB, bundled to 10.8 KB gzipped in our browser test, and returned 0 audit findings.
When should you not use tweetnacl?
You are designing a new application protocol. TweetNaCl supplies primitives, not message framing, key rotation, replay protection, identity binding, or negotiation.
Discussed on
- hnTinySSH is a small SSH server using NaCl, TweetNaCl322 points
- hnTinySSH is a small SSH server using NaCl, TweetNaCl129 points
- hnSPARKNaCl: A verified, fast re-implementation of TweetNaCl70 points
- hnTweetNaCl.js57 points
- hnTweetNaCL16 points
Use it if
- An existing protocol explicitly requires NaCl-compatible `secretbox`, `box`, or TweetNaCl Ed25519 byte formats.
- The same small primitive API must run in Node and browsers with no runtime dependencies.
- Inputs and outputs are already byte arrays, and your protocol separately defines framing, key identity, nonce allocation, and versioning.
- You need XSalsa20-Poly1305, which the README notes is the main primitive here that Web Crypto does not supply.
- You are designing a new application protocol. TweetNaCl supplies primitives, not message framing, key rotation, replay protection, identity binding, or negotiation.
- Web Crypto covers your X25519, Ed25519, or SHA-512 need. The project README now recommends the platform API where possible.
- Ciphertexts must commit to exactly one key. The README warns that `secretbox` and `box` use an authenticated-encryption construction without key commitment.
- Signature bytes are used as unique identifiers or canonical proofs. The implemented Ed25519 form is malleable, so a different valid signature can exist for the same message and public key.
- You need active releases, modern package exports, or protocol-level helpers. npm's latest version is still 1.0.3 from February 2020, the CommonJS package has no exports map, and recent source activity is sparse.
Setup reality
We installed tweetnacl 1.0.3 in a fresh Node 22 Bookworm sandbox in 0.6 seconds. It left 1 package and 1 MB on disk. npm audit found 0 known vulnerabilities at every severity. The package has 0 direct dependencies and 0 peer dependencies, is 196 KB unpacked, includes TypeScript declarations, and uses the Unlicense. It is CommonJS with no exports map; both require() and ESM import worked in our checks.
All APIs consume and return Uint8Array, so text encoding and wire framing are your work. Our esbuild browser import measured 32.7 KB minified and 10.8 KB gzipped. In Node, Buffer inputs work because they extend Uint8Array, but outputs remain byte arrays. Use Buffer.from(view) to copy the visible bytes; constructing from view.buffer can expose unrelated bytes outside a subarray. Validate decoded key, nonce, and signature lengths before calling a primitive.
Nonce uniqueness is a protocol requirement. secretbox needs a fresh 24-byte nonce for each message under one key, and box needs one for each message under a key pair. Nonces are public and may travel beside ciphertext. Key generation requires a cryptographic random source from Web Crypto or Node crypto; it throws when none exists. setPRNG replaces that source globally, so it is unsuitable for Math.random or an unreviewed callback.
Authenticated decryption returns null on a wrong key, wrong nonce, or changed ciphertext. Check for null, since an empty Uint8Array is valid plaintext. The README also documents signature malleability, SHA-512 length-extension exposure, missing key commitment, and the inability of JavaScript engines to promise physical constant-time execution or reliable memory erasure. Those are protocol constraints, not issues npm audit can detect.
Patterns
Create separate box and signing keys generate-key-pairs
import nacl from 'tweetnacl';
const boxKeys = nacl.box.keyPair();
const signKeys = nacl.sign.keyPair();
storeBoxPublicKey(boxKeys.publicKey);
storeSigningPublicKey(signKeys.publicKey);Box and Ed25519 keys have different purposes and secret-key lengths; never reuse 1 pair for both jobs.
Encrypt to a recipient key encrypt-public-key-box
const message = new TextEncoder().encode('hello');
const nonce = nacl.randomBytes(nacl.box.nonceLength);
const ciphertext = nacl.box(
message, nonce, recipientPublicKey, senderSecretKey,
);Transmit the 24-byte nonce with the ciphertext and never repeat it for the same sender and recipient key pair.
Reject a modified public-key box open-public-key-box
const plaintext = nacl.box.open(
ciphertext, nonce, senderPublicKey, recipientSecretKey,
);
if (plaintext === null) {
throw new Error('authentication failed');
}`box.open` returns `null` on failure; a 0-byte `Uint8Array` is valid plaintext and must remain distinct.
Prefix ciphertext with its nonce frame-box-message
const payload = new Uint8Array(nonce.length + ciphertext.length);
payload.set(nonce, 0);
payload.set(ciphertext, nonce.length);
const receivedNonce = payload.slice(0, nacl.box.nonceLength);
const receivedBox = payload.slice(nacl.box.nonceLength);Nonce bytes are public, but your protocol must also authenticate external version, sender, and content-type metadata.
Protect bytes with a shared key encrypt-secretbox
const key = nacl.randomBytes(nacl.secretbox.keyLength);
const nonce = nacl.randomBytes(nacl.secretbox.nonceLength);
const ciphertext = nacl.secretbox(message, nonce, key);
const opened = nacl.secretbox.open(ciphertext, nonce, key);
if (opened === null) throw new Error('invalid secretbox');XSalsa20-Poly1305 here is not key-committing, which matters if a receiver tests more than 1 candidate key.
Reuse a box shared key precompute-shared-key
const shared = nacl.box.before(peerPublicKey, localSecretKey);
const first = nacl.box.after(messageA, nonceA, shared);
const second = nacl.box.after(messageB, nonceB, shared);
const opened = nacl.box.open.after(first, nonceA, shared);Every message still needs its own 24-byte nonce, and the precomputed value must be protected like any other secret key.
Create and verify a detached signature sign-detached
const signature = nacl.sign.detached(message, secretKey);
const valid = nacl.sign.detached.verify(message, signature, publicKey);
if (!valid) throw new Error('signature rejected');Version 1.0.3 fixes incorrect signature generation; signature bytes are malleable and should not be unique record ids.
Recover a key pair from a seed derive-signing-key
if (seed.length !== nacl.sign.seedLength) {
throw new Error('expected 32-byte seed');
}
const pair = nacl.sign.keyPair.fromSeed(seed);The 32-byte seed needs full cryptographic entropy; a password or one ordinary password hash is not a substitute.
Calculate a SHA-512 digest hash-bytes
const message = new TextEncoder().encode('event');
const digest = nacl.hash(message);
if (digest.length !== nacl.hash.hashLength) throw new Error('bad digest');This returns a raw 64-byte SHA-512 digest; do not construct a MAC as `hash(secret || message)` because length extension applies.
Compare non-empty equal-length values compare-byte-secrets
const same = candidate.length === expected.length &&
candidate.length > 0 &&
nacl.verify(candidate, expected);
if (!same) throw new Error('token mismatch');`nacl.verify` returns false for 0-length arrays and mismatched lengths, while JavaScript prevents a physical constant-time guarantee.
Copy a public key to base64 encode-key-base64
const encoded = Buffer.from(publicKey).toString('base64');
const decoded = new Uint8Array(Buffer.from(encoded, 'base64'));
if (decoded.length !== nacl.box.publicKeyLength) {
throw new Error('invalid public key');
}`Buffer.from(view)` copies the visible 32 bytes; `Buffer.from(view.buffer)` can include bytes outside a returned subarray.
Install a platform random source supply-secure-prng
nacl.setPRNG((target, length) => {
const bytes = readHardwareRandom(length);
target.set(bytes.subarray(0, length));
});`setPRNG` replaces the random source globally; use it only for a reviewed cryptographic generator and never `Math.random`.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| libsodium-wrappers | npm | Use it for a wider maintained Sodium API with password hashing, sealed boxes, and additional modern constructions. |
| @noble/ed25519 | npm | Use it when the requirement is specifically a modern audited Ed25519 implementation rather than the NaCl primitive set. |
| jose | npm | Use it for interoperable JWT, JWS, JWE, JWK, and OAuth-facing key formats instead of inventing message framing. |
More security guides
cryptography · pyjwt · jose · requests-oauthlib · oauthlib · dompurify · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

