PyNaCl review
PyNaCl 1.6.2 is the Python binding for libsodium's NaCl constructions. Its high-level objects cover authenticated secret-key encryption, Curve25519 boxes, anonymous sealed boxes, Ed25519 signatures, hashing, and Argon2 password operations without asking callers to assemble cipher modes. Our Python 3.12 package included compiled extensions and type information. The current release replaces its bundled libsodium with a 2025-12-31 build of 1.0.20-stable to fix CVE-2025-69277.
PyNaCl 1.6.2 installed in 0.2 seconds and occupied 5 MB across 3 packages with no pip-audit findings, but top-level import _sodium failed exactly with ModuleNotFoundError in our sandbox. Install it for NaCl-compatible protocols through the documented nacl modules; choose cryptography for certificates, RSA, or AES interoperability.
We installed it
| Install | ✓ · 0.2s | 3 packages on disk · 5 MB |
| Import | ✗ | import _sodium · compiled extensions · py.typed · requires Python >=3.8 |
| Known vulns | 0 | (pip-audit) |
Answers from our run
Does PyNaCl install cleanly?
Yes. In a fresh container with an empty cache, pip install PyNaCl finished in 0.2s, leaving 3 packages and 5 MB on disk. pip-audit reported no known vulnerabilities.
What does PyNaCl need to run?
Python >=3.8, and a platform wheel with compiled extensions. In our run import _sodium failed, so it needs extra system packages, and the package ships py.typed for type checkers.
PyNaCl or cryptography: which should you use?
cryptography: Use it for X.509, RSA, AES-GCM, PKCS serialization, and protocols defined outside the NaCl family. PyNaCl 1.6.2 installed in 0.2 seconds and occupied 5 MB across 3 packages with no pip-audit findings, but top-level import _sodium failed exactly with ModuleNotFoundError in our sandbox.
When should you not use PyNaCl?
Your protocol requires X.509, RSA, AES-GCM, PKCS formats, or certificate-chain validation. Those formats sit outside PyNaCl's NaCl-focused API.
Use it if
- An existing protocol uses libsodium-compatible SecretBox, Box, SealedBox, Ed25519, or Argon2 data.
- You want high-level authenticated-encryption objects with fixed key and nonce sizes instead of composing primitives.
- A sender must encrypt to a recipient's public key without owning a sender key; SealedBox is built for that exchange.
- Python services and another libsodium implementation need to share encoded keys, signatures, or ciphertexts.
- Your protocol requires X.509, RSA, AES-GCM, PKCS formats, or certificate-chain validation. Those formats sit outside PyNaCl's NaCl-focused API.
- A contract demands a named FIPS-validated cryptographic module. PyNaCl's documentation and package metadata do not make that validation claim.
- Password hashing is the only operation. argon2-cffi presents a smaller password-specific surface without public-key and encryption bindings.
- Your platform has no matching wheel and the build environment lacks a C toolchain and make. Source installation compiles bundled libsodium unless you supply a system copy.
- Code imports a top-level module called _sodium. That exact import failed in our Python 3.12 sandbox with ModuleNotFoundError: No module named '_sodium'; documented code imports nacl or nacl.bindings.
Setup reality
We installed PyNaCl 1.6.2 in a fresh Python 3.12 Bookworm container, and the install completed in 0.2 seconds. It left 3 packages using 5 MB. The measured package had 8 direct dependencies, required Python 3.8 or newer, shipped py.typed plus compiled .so files, and carried Apache-2.0 licensing. pip-audit reported 0 known vulnerabilities.
Our direct import of _sodium failed with ModuleNotFoundError: No module named '_sodium'. PyNaCl's own code loads the extension as nacl._sodium, while application examples use nacl.secret, nacl.public, nacl.signing, nacl.pwhash, or nacl.bindings. The failed top-level probe identifies an import-path mistake rather than proof that every documented nacl import works in that sandbox.
Binary wheels avoid compilation on covered Python, operating-system, and CPU combinations. A source install builds the bundled libsodium and needs C build tools plus make. SODIUM_INSTALL=system links against a system copy, LIBSODIUM_MAKE_ARGS passes flags to make, and version 1.6.1 added MAKE for selecting another make binary. These switches also make your build responsible for matching and patching the native library.
Keys, salts, and nonces become application state. Put encoded private keys in a secret store; encoding provides no confidentiality. SecretBox, Box, and Aead generate a nonce when you omit it and prepend that nonce to the returned encrypted message. Decrypt and verify operations raise narrow exceptions on bad input. Version 1.6.2 matters for deployed wheels because it updates bundled libsodium for CVE-2025-69277.
Patterns
Encrypt with SecretBox encrypt-secret-message
from nacl.secret import SecretBox
from nacl.utils import random
key = random(SecretBox.KEY_SIZE)
box = SecretBox(key)
encrypted = box.encrypt(b'private message')
plaintext = box.decrypt(encrypted)SecretBox.encrypt() generates and prepends a nonce when none is passed. Store its 32-byte key separately from the ciphertext.
Encode a key for secret storage encode-private-key
from nacl.encoding import Base64Encoder
from nacl.secret import SecretBox
from nacl.utils import random
raw_key = random(SecretBox.KEY_SIZE)
stored = Base64Encoder.encode(raw_key).decode('ascii')
restored = Base64Encoder.decode(stored.encode('ascii'))
box = SecretBox(restored)Base64 changes representation only. The encoded 32-byte SecretBox key still belongs in a credential store.
Exchange a message between keypairs encrypt-authenticated-box
from nacl.public import Box, PrivateKey
alice = PrivateKey.generate()
bob = PrivateKey.generate()
ciphertext = Box(alice, bob.public_key).encrypt(b'hello')
plaintext = Box(bob, alice.public_key).decrypt(ciphertext)Box authenticates the sender key to its recipient, but the result is not a signature that an unrelated third party can verify.
Encrypt with only a recipient key encrypt-sealed-box
from nacl.public import PrivateKey, SealedBox
recipient = PrivateKey.generate()
sealed = SealedBox(recipient.public_key).encrypt(b'anonymous message')
plaintext = SealedBox(recipient).decrypt(sealed)SealedBox hides content from everyone except the recipient. Anyone holding the public key can create a valid sealed message, so sender identity is absent.
Verify an attached Ed25519 signature sign-and-verify
from nacl.exceptions import BadSignatureError
from nacl.signing import SigningKey
signing_key = SigningKey.generate()
signed = signing_key.sign(b'release manifest')
try:
message = signing_key.verify_key.verify(signed)
except BadSignatureError:
message = NoneVerifyKey.verify() returns the original message on success and raises BadSignatureError on a bad signature; it does not return a boolean.
Check a detached signature verify-detached-signature
from nacl.signing import SigningKey
signing_key = SigningKey.generate()
payload = b'artifact bytes'
signature = signing_key.sign(payload).signature
signing_key.verify_key.verify(payload, signature)Detached verification covers the exact payload bytes. Newline conversion or archive rebuilding invalidates the signature.
Store and verify a password hash-login-password
from nacl import pwhash
from nacl.exceptions import InvalidkeyError
stored = pwhash.str(password.encode())
try:
pwhash.verify(stored, attempt.encode())
valid = True
except InvalidkeyError:
valid = Falsepwhash.str() returns a complete verifier containing its algorithm and parameters. Store that full byte string rather than splitting fields.
Derive a key from a passphrase derive-encryption-key
from nacl import pwhash, utils
from nacl.secret import SecretBox
salt = utils.random(pwhash.argon2id.SALTBYTES)
key = pwhash.argon2id.kdf(
SecretBox.KEY_SIZE,
passphrase.encode(),
salt,
opslimit=pwhash.argon2id.OPSLIMIT_MODERATE,
memlimit=pwhash.argon2id.MEMLIMIT_MODERATE,
)The salt and both Argon2id limits are needed to derive the same 32-byte key later. Losing any one makes the ciphertext unreadable.
Bind metadata with Aead authenticate-metadata
from nacl.secret import Aead
from nacl.utils import random
key = random(Aead.KEY_SIZE)
aead = Aead(key)
aad = b'record=42'
encrypted = aead.encrypt(b'token', aad)
plaintext = aead.decrypt(encrypted, aad)Aead leaves associated data visible while authenticating it. Supplying metadata other than record=42 makes decryption fail.
Create a fixed BLAKE2b digest digest-with-blake2b
from nacl.encoding import HexEncoder
from nacl.hash import blake2b
digest = blake2b(
b'file contents',
digest_size=32,
encoder=HexEncoder,
)The 32-byte digest length becomes part of the surrounding protocol. Producers and verifiers must choose the same size and encoding.
Compare equal-length values compare-secret-bytes
from nacl.bindings import sodium_memcmp
if sodium_memcmp(provided_token, expected_token):
accept_request()sodium_memcmp requires equal-length byte strings and performs a constant-time comparison. Normalize public encoding before calling it.
Handle authentication failure reject-invalid-ciphertext
from nacl.exceptions import CryptoError
try:
plaintext = box.decrypt(ciphertext)
except CryptoError:
plaintext = NoneCryptoError can mean a wrong key, corrupt bytes, or tampering. PyNaCl does not expose authenticated partial plaintext after failure.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| cryptography | PyPI | Use it for X.509, RSA, AES-GCM, PKCS serialization, and protocols defined outside the NaCl family. |
| argon2-cffi | PyPI | Use it when password hashing and verification are the entire cryptographic requirement. |
| pycryptodome | PyPI | Use it when interoperability requires a broader catalog of traditional cipher, hash, and public-key algorithms. |
More security guides
cryptography · pyjwt · jose · dompurify · requests-oauthlib · jsonwebtoken · 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.

