mrkeyoor.com_
Sun 20 Sept 02:42 UTC
PyPISecurityupdated 18 Sept 2026

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.

Verdict

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

Lab card: what happened when we installed PyNaClScreenshot of PyNaCl documentation
Install✓ · 0.2s3 packages on disk · 5 MB
Importimport _sodium · compiled extensions · py.typed · requires Python >=3.8
Known vulns0(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.

API stability5/5PyNaCl 1.6.2 keeps the established SecretBox, Aead, Box, SealedBox, SigningKey, VerifyKey, hashing, and pwhash interfaces. The patch changes the bundled libsodium build without altering those high-level calls. Version 1.6.0 did remove Python 3.6 and 3.7 and add low-level bindings, so compatibility breaks are more likely at the runtime and native-package edges than in routine encrypt or verify code.
Docs4/5The official documentation separates secret-key encryption, public-key boxes, sealed boxes, signatures, encoders, hashing, and password operations, with concrete exception behavior and nonce warnings. Installation pages document binary wheels, bundled versus system libsodium, MAKE, and parallel make flags. The repository README is only a feature overview, and nacl.bindings exposes low-level names that require prior libsodium knowledge.
Maintenance4/5GitHub shows 1,205 stars, 57 open issues and pull requests combined, and a push on 2026-08-25; the repository is active and unarchived. Release 1.6.2 shipped on 2026-01-01 specifically to replace bundled libsodium for CVE-2025-69277. The three-year gap between 1.5.0 and 1.6.0 means releases are not frequent, so consumers should watch upstream libsodium advisories instead of relying on cadence alone.
Ecosystem4/5PyNaCl records 51,655,694 weekly downloads and gives Python direct compatibility with libsodium constructions used by SSH, signing, messaging, and secret-storage software. Its high-level and bindings layers cover the same native dependency, and current metadata includes CPython, PyPy, free-threaded Python 3.14, and Windows ARM classifiers. The reach is intentionally narrower than cryptography because certificates, RSA, and common PKCS formats are outside its purpose.

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

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

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

pwhash.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 = None

CryptoError can mean a wrong key, corrupt bytes, or tampering. PyNaCl does not expose authenticated partial plaintext after failure.

Alternatives

PackageRegistryPick it when
cryptographyPyPIUse it for X.509, RSA, AES-GCM, PKCS serialization, and protocols defined outside the NaCl family.
argon2-cffiPyPIUse it when password hashing and verification are the entire cryptographic requirement.
pycryptodomePyPIUse 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.