PyNaCl
PyNaCl is a Python binding to libsodium, the maintained fork of Daniel Bernstein's NaCl crypto library. Instead of handing you ciphers and modes to assemble yourself, it hands you finished boxes: SecretBox for symmetric encryption with a shared key, Box and SealedBox for public-key encryption between Curve25519 keypairs, SigningKey and VerifyKey for Ed25519 signatures, and pwhash for Argon2 password hashing. Each one picks the algorithm, nonce size, and MAC for you, so there is no AES-vs-ChaCha or CBC-vs-GCM decision to get wrong. Most people never install it on purpose: it arrives as a dependency of paramiko, discord.py, or a blockchain SDK.
The right pick when your problem is stated in NaCl terms (Ed25519, Curve25519, SecretBox, Argon2) and the wrong pick when it is stated in standards terms (X.509, RSA, AES-GCM). Releases are rare, but the API has been stable for years and the wheel coverage is genuinely good.
Use it if
- You need authenticated encryption and want an API where picking the wrong mode is not an option: SecretBox is XSalsa20-Poly1305 and there is no knob to turn it into something weaker
- You are working with Ed25519 signatures or Curve25519 key exchange, which is what SSH, Signal, WireGuard, and most modern token formats actually use
- You want Argon2id password hashing without a second dependency, since nacl.pwhash ships it in the same wheel
- You need to encrypt something to a recipient whose public key you have, without either side doing a handshake: SealedBox does exactly that in two lines
- You need standards interop: X.509 certificates, RSA, PKCS#7, JWT with RS256, or AES-GCM against another language's stack. PyNaCl deliberately exposes only the NaCl primitive set, so reach for cryptography instead
- You need a FIPS 140 validated module for a compliance audit. libsodium is not FIPS validated and PyNaCl makes no such claim
- Your only requirement is password hashing. argon2-cffi is a much smaller dependency doing that one job, with a friendlier PasswordHasher API and parameter presets
- You expect frequent releases. Between 1.5.0 (January 2022) and 1.6.0 (September 2025) there were no releases at all, and the README is roughly thirty lines pointing at readthedocs
- You are on a platform with no published wheel. The sdist compiles a bundled libsodium, which means a C toolchain and make in your build image, and that failure mode shows up in CI long before it shows up locally
Setup reality
pip install pynacl is usually a wheel download and nothing else. The 1.6.2 release ships abi3 wheels covering CPython 3.8 and up on manylinux2014, manylinux_2_28, manylinux_2_34, musllinux_1_2 (x86_64 and aarch64), macOS universal2, and Windows win32, amd64, and arm64, plus a separate cp314t set for free-threaded Python. Miss that list and pip falls back to the sdist, which builds the bundled libsodium from source: you need a C compiler and make in the image, and slim Docker bases do not have them. Set SODIUM_INSTALL=system to link against a distro libsodium instead, or MAKE=gmake (added in 1.6.1) when the default make is not the one you want. On Python 3.9+ it also pulls cffi>=2.0.0, which itself needs libffi headers if no cffi wheel matches.
Patterns
Symmetric encryption with a shared keysecretbox-encrypt-decrypt
import nacl.secret
import nacl.utils
key = nacl.utils.random(nacl.secret.SecretBox.KEY_SIZE) # 32 bytes
box = nacl.secret.SecretBox(key)
encrypted = box.encrypt(b'attack at dawn')
plaintext = box.decrypt(encrypted)
assert plaintext == b'attack at dawn'encrypt() generates a fresh random nonce and prepends it, so the returned value is self-contained; do not pass your own nonce unless you have a counter scheme that guarantees it never repeats for a given key.
Generate a key and store it as textgenerate-and-store-key
import nacl.secret
import nacl.utils
from nacl.encoding import Base64Encoder
key = nacl.utils.random(nacl.secret.SecretBox.KEY_SIZE)
print(Base64Encoder.encode(key).decode()) # put this in your secret store
# later, reading it back
key = Base64Encoder.decode(os.environ['APP_SECRET_KEY'].encode())
box = nacl.secret.SecretBox(key)SecretBox keys must be exactly 32 random bytes. Do not use a passphrase directly as a key: run it through nacl.pwhash.argon2i.kdf first, shown below.
Encrypt between two keypairspublic-key-box
from nacl.public import PrivateKey, PublicKey, Box
alice_sk = PrivateKey.generate()
bob_sk = PrivateKey.generate()
# Alice sends to Bob
box = Box(alice_sk, bob_sk.public_key)
ciphertext = box.encrypt(b'hello bob')
# Bob reads it
assert Box(bob_sk, alice_sk.public_key).decrypt(ciphertext) == b'hello bob'Box authenticates the sender, so Bob knows the message came from Alice's key. That also means Bob can forge a message that looks like Alice's to himself; use signatures if you need third-party provable origin.
Encrypt to a public key without having your ownsealed-box-anonymous
from nacl.public import PrivateKey, SealedBox
recipient_sk = PrivateKey.generate()
recipient_pk = recipient_sk.public_key
# sender only needs the public key
ciphertext = SealedBox(recipient_pk).encrypt(b'anonymous tip')
# only the holder of the private key can open it
print(SealedBox(recipient_sk).decrypt(ciphertext))SealedBox gives no sender authentication at all: anyone with the public key can produce a valid ciphertext, so treat the contents as untrusted input.
Ed25519 signaturessign-and-verify
from nacl.signing import SigningKey, VerifyKey
from nacl.exceptions import BadSignatureError
signing_key = SigningKey.generate()
signed = signing_key.sign(b'release-v1.2.3')
verify_key = signing_key.verify_key
try:
verify_key.verify(signed)
except BadSignatureError:
raise SystemExit('signature check failed')sign() returns a SignedMessage, which is a bytes subclass holding signature plus message together; verify() returns the message on success and raises on failure, it never returns False.
Keep the signature separate from the payloaddetached-signature
from nacl.signing import SigningKey, VerifyKey
from nacl.encoding import HexEncoder
signing_key = SigningKey.generate()
signature = signing_key.sign(payload).signature # 64 raw bytes
# publish the verify key once, ship signature alongside the file
verify_key_hex = signing_key.verify_key.encode(encoder=HexEncoder)
VerifyKey(verify_key_hex, encoder=HexEncoder).verify(payload, signature)Detached is what you want for signing artifacts: the file on disk stays byte-identical and the .sig sits next to it.
Store and check a password with Argon2idhash-password
import nacl.pwhash
from nacl.exceptions import InvalidkeyError
hashed = nacl.pwhash.str(b'correct horse battery staple')
# hashed is a bytes string starting with $argon2id$ ; store it as-is
try:
nacl.pwhash.verify(hashed, password_attempt.encode())
ok = True
except InvalidkeyError:
ok = Falseverify() raises InvalidkeyError on a wrong password rather than returning False, so a bare except that swallows it will silently authenticate everyone.
Turn a passphrase into a SecretBox keyderive-key-from-password
import nacl.pwhash
import nacl.secret
import nacl.utils
salt = nacl.utils.random(nacl.pwhash.argon2i.SALTBYTES) # store next to the ciphertext
key = nacl.pwhash.argon2i.kdf(
nacl.secret.SecretBox.KEY_SIZE,
passphrase.encode(),
salt,
opslimit=nacl.pwhash.argon2i.OPSLIMIT_SENSITIVE,
memlimit=nacl.pwhash.argon2i.MEMLIMIT_SENSITIVE,
)
box = nacl.secret.SecretBox(key)The salt is not a secret but it must be stored: without the exact same salt and the same opslimit and memlimit you get a different key and decryption fails.
Encrypt with unencrypted but authenticated metadataaead-with-associated-data
import nacl.secret
import nacl.utils
key = nacl.utils.random(nacl.secret.Aead.KEY_SIZE)
aead = nacl.secret.Aead(key)
# aad travels in the clear but any tampering breaks decryption
ciphertext = aead.encrypt(b'card token', aad=b'user_id=42')
plaintext = aead.decrypt(ciphertext, aad=b'user_id=42')Aead is XChaCha20-Poly1305. You must pass the exact same aad bytes when decrypting; a mismatch raises CryptoError, which is how you bind a ciphertext to its row in the database.
Keyed and unkeyed BLAKE2b hashingblake2b-hash
import nacl.hash
import nacl.utils
from nacl.encoding import HexEncoder
digest = nacl.hash.blake2b(b'file contents', encoder=HexEncoder)
mac_key = nacl.utils.random(32)
mac = nacl.hash.blake2b(b'file contents', key=mac_key, digest_size=32, encoder=HexEncoder)BLAKE2b keyed mode is a MAC on its own, so you do not need HMAC on top of it; digest_size maxes out at 64 bytes.
Compare secrets without leaking timingconstant-time-compare
from nacl.bindings import sodium_memcmp
if sodium_memcmp(provided_token.encode(), expected_token.encode()):
grant_access()Both inputs must be the same length or sodium_memcmp raises; if lengths can differ, hash both with blake2b first and compare the digests.
Catch decryption failures properlyhandle-crypto-errors
from nacl.exceptions import CryptoError
try:
plaintext = box.decrypt(ciphertext)
except CryptoError:
# wrong key, truncated data, or someone flipped a bit
logger.warning('ciphertext failed authentication')
return NoneCryptoError is the base class for BadSignatureError, InvalidkeyError, and the generic decryption failure; never log the ciphertext or key alongside the warning.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| cryptography | PyPI | You need X.509, RSA, AES-GCM, or anything that has to interoperate with a non-NaCl stack |
| argon2-cffi | PyPI | Password hashing is your only need and you want a focused dependency with tuned presets |
| pysodium | PyPI | You want thin ctypes bindings over a system libsodium you already install and patch yourself |
| libnacl | PyPI | You want ctypes bindings with no cffi and no build step, against a preinstalled libsodium |