mrkeyoor.com_
Thu 06 Aug 01:02 UTC
PyPISecurityupdated 05 Aug 2026

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.

Verdict

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.

API stability5/5SecretBox, Box, SealedBox, SigningKey, and pwhash have kept the same shape since 1.x; 1.6.0 only removed Python 3.6 and 3.7 and added new low level bindings alongside the existing ones.
Docs4/5readthedocs has a runnable page per primitive and the examples are executed by sphinx doctest in CI, so they stay correct; the README itself is a stub and the low level nacl.bindings surface is barely documented.
Maintenance3/5PyCA owns it and 1.6.2 shipped January 2026 to pull in a libsodium build fixing CVE-2025-69277, but 1.5.0 to 1.6.0 was a gap of over three years and there are 52 open issues on top of open PRs.
Ecosystem4/5About 57.5M weekly downloads, mostly transitive through paramiko, discord.py, and blockchain SDKs; the trade-off is that most of those users never chose it and the tutorial ecosystem is thin compared with cryptography.

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
Skip it if

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 = False

verify() 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 None

CryptoError is the base class for BadSignatureError, InvalidkeyError, and the generic decryption failure; never log the ciphertext or key alongside the warning.

Alternatives

PackageRegistryPick it when
cryptographyPyPIYou need X.509, RSA, AES-GCM, or anything that has to interoperate with a non-NaCl stack
argon2-cffiPyPIPassword hashing is your only need and you want a focused dependency with tuned presets
pysodiumPyPIYou want thin ctypes bindings over a system libsodium you already install and patch yourself
libnaclPyPIYou want ctypes bindings with no cffi and no build step, against a preinstalled libsodium