Skip to main content Skip to sidebar

Libsodium encryption in Golang

Libsodium is a modern, easy-to-use crypto library that grew out of the NaCl (Networking and Cryptography Library) project. Instead of handing you a pile of primitives and asking you to assemble them safely, it exposes a small set of opinionated, misuse-resistant APIs with sane defaults baked in. The Go standard library ships a native implementation of the original NaCl primitives under golang.org/x/crypto/nacl and golang.org/x/crypto, so you can use libsodium-compatible constructions without any cgo or C dependency.

This post walks through the primitives you actually need in day-to-day Go code: authenticated symmetric encryption, public-key (box) encryption, sealed boxes, key derivation, and password hashing.

Why Libsodium Instead of Rolling Your Own

The AES modes are flexible but leave a lot of dangerous choices to the developer: which mode, how to generate the IV, whether to add a MAC, how to compare tags in constant time. Libsodium removes those choices. Every construction below is authenticated (AEAD), uses well-vetted primitives, and fails loudly on tampering.

TaskLibsodium / NaCl constructionUnderlying primitives
Symmetric authenticated encryptionsecretboxXSalsa20 + Poly1305
Public-key authenticated encryptionboxX25519 + XSalsa20 + Poly1305
Anonymous public-key encryptionsealedboxX25519 + XSalsa20 + Poly1305
Modern AEADChaCha20-Poly1305ChaCha20 + Poly1305
Key derivationHKDF / BLAKE2b-
Password hashingArgon2id-

The Go packages that map to these are golang.org/x/crypto/nacl/secretbox, golang.org/x/crypto/nacl/box, golang.org/x/crypto/chacha20poly1305, golang.org/x/crypto/hkdf, and golang.org/x/crypto/argon2.

Pure Go vs cgo Bindings

There are two ways to use libsodium primitives from Go:

  • cgo bindings such as github.com/jamesruan/sodium wrap the actual C libsodium library. You get every primitive libsodium offers, but you pull in a C toolchain, complicate cross-compilation, and lose the simplicity of a static binary.
  • Pure Go via golang.org/x/crypto implements the same primitives (Salsa20, ChaCha20, Poly1305, X25519, Argon2, BLAKE2b) in Go. Wire-compatible with libsodium for the shared constructions, no cgo, trivial cross-compilation.

For most services the pure-Go path is the right default, and it is what the examples below use.

go get golang.org/x/crypto

Secret-Key Authenticated Encryption (secretbox)

secretbox is the workhorse: symmetric encryption where a single 32-byte key both encrypts and authenticates. It uses XSalsa20 for the stream cipher and Poly1305 for the authentication tag. The 24-byte nonce is large enough to be generated randomly for every message without meaningful collision risk.

The critical rule: never reuse a (key, nonce) pair. A random 24-byte nonce per message satisfies this comfortably.

package main

import (
	"crypto/rand"
	"errors"
	"fmt"

	"golang.org/x/crypto/nacl/secretbox"
)

// encryptSecretBox encrypts plaintext with a 32-byte key and returns
// nonce||ciphertext so the nonce travels with the message.
func encryptSecretBox(plaintext, key []byte) ([]byte, error) {
	if len(key) != 32 {
		return nil, errors.New("secretbox: key must be 32 bytes")
	}

	var secretKey [32]byte
	copy(secretKey[:], key)

	var nonce [24]byte
	if _, err := rand.Read(nonce[:]); err != nil {
		return nil, err
	}

	// Seal appends the ciphertext to the nonce prefix so the result is
	// nonce (24 bytes) followed by the authenticated ciphertext.
	encrypted := secretbox.Seal(nonce[:], plaintext, &nonce, &secretKey)
	return encrypted, nil
}

// decryptSecretBox reverses encryptSecretBox. A tampered or truncated
// message fails the Poly1305 check and returns an error.
func decryptSecretBox(encrypted, key []byte) ([]byte, error) {
	if len(key) != 32 {
		return nil, errors.New("secretbox: key must be 32 bytes")
	}
	if len(encrypted) < 24 {
		return nil, errors.New("secretbox: message too short")
	}

	var secretKey [32]byte
	copy(secretKey[:], key)

	var nonce [24]byte
	copy(nonce[:], encrypted[:24])

	plaintext, ok := secretbox.Open(nil, encrypted[24:], &nonce, &secretKey)
	if !ok {
		return nil, errors.New("secretbox: decryption failed")
	}
	return plaintext, nil
}

func main() {
	key := make([]byte, 32)
	if _, err := rand.Read(key); err != nil {
		panic(err)
	}

	ciphertext, err := encryptSecretBox([]byte("attack at dawn"), key)
	if err != nil {
		panic(err)
	}

	plaintext, err := decryptSecretBox(ciphertext, key)
	if err != nil {
		panic(err)
	}

	fmt.Printf("decrypted: %s\n", plaintext)
}

Prepending the nonce to the ciphertext is the idiomatic pattern: the nonce is not secret, only unique, so it can travel in the clear alongside the message.

Public-Key Authenticated Encryption (box)

box lets two parties who each have a key pair exchange authenticated, confidential messages. It performs an X25519 Diffie-Hellman exchange between the sender’s private key and the recipient’s public key, then encrypts with the same XSalsa20-Poly1305 construction as secretbox.

Because it is authenticated, the recipient is guaranteed the message came from the holder of the sender’s private key and was not modified.

package main

import (
	"crypto/rand"
	"errors"
	"fmt"

	"golang.org/x/crypto/nacl/box"
)

// encryptBox encrypts a message from the sender to the recipient.
// Output is nonce||ciphertext.
func encryptBox(plaintext []byte, recipientPub, senderPriv *[32]byte) ([]byte, error) {
	var nonce [24]byte
	if _, err := rand.Read(nonce[:]); err != nil {
		return nil, err
	}

	encrypted := box.Seal(nonce[:], plaintext, &nonce, recipientPub, senderPriv)
	return encrypted, nil
}

// decryptBox decrypts a message from the sender using the recipient's key.
func decryptBox(encrypted []byte, senderPub, recipientPriv *[32]byte) ([]byte, error) {
	if len(encrypted) < 24 {
		return nil, errors.New("box: message too short")
	}

	var nonce [24]byte
	copy(nonce[:], encrypted[:24])

	plaintext, ok := box.Open(nil, encrypted[24:], &nonce, senderPub, recipientPriv)
	if !ok {
		return nil, errors.New("box: decryption failed")
	}
	return plaintext, nil
}

func main() {
	// Each party generates a key pair once.
	senderPub, senderPriv, err := box.GenerateKey(rand.Reader)
	if err != nil {
		panic(err)
	}

	recipientPub, recipientPriv, err := box.GenerateKey(rand.Reader)
	if err != nil {
		panic(err)
	}

	ciphertext, err := encryptBox([]byte("meet me at the docks"), recipientPub, senderPriv)
	if err != nil {
		panic(err)
	}

	plaintext, err := decryptBox(ciphertext, senderPub, recipientPriv)
	if err != nil {
		panic(err)
	}

	fmt.Printf("decrypted: %s\n", plaintext)
}

When the same two parties exchange many messages, use box.Precompute to derive the shared key once and reuse it, avoiding a Diffie-Hellman operation per message.

Anonymous Encryption (sealed box)

Sometimes the sender should stay anonymous: anyone can encrypt to a recipient’s public key, but the recipient cannot identify who sent it. Libsodium calls this a sealed box. Internally it generates an ephemeral key pair per message, performs a box operation, and prepends the ephemeral public key. The ephemeral private key is discarded, so even the sender cannot decrypt afterwards.

golang.org/x/crypto/nacl/box provides SealAnonymous and OpenAnonymous for exactly this.

package main

import (
	"crypto/rand"
	"errors"
	"fmt"

	"golang.org/x/crypto/nacl/box"
)

// sealAnonymous encrypts to recipientPub without revealing the sender.
func sealAnonymous(plaintext []byte, recipientPub *[32]byte) ([]byte, error) {
	// A nil out slice means Seal allocates a fresh buffer.
	return box.SealAnonymous(nil, plaintext, recipientPub, rand.Reader)
}

// openAnonymous decrypts a sealed box using the recipient's key pair.
func openAnonymous(sealed []byte, recipientPub, recipientPriv *[32]byte) ([]byte, error) {
	plaintext, ok := box.OpenAnonymous(nil, sealed, recipientPub, recipientPriv)
	if !ok {
		return nil, errors.New("sealedbox: decryption failed")
	}
	return plaintext, nil
}

func main() {
	recipientPub, recipientPriv, err := box.GenerateKey(rand.Reader)
	if err != nil {
		panic(err)
	}

	sealed, err := sealAnonymous([]byte("anonymous tip"), recipientPub)
	if err != nil {
		panic(err)
	}

	plaintext, err := openAnonymous(sealed, recipientPub, recipientPriv)
	if err != nil {
		panic(err)
	}

	fmt.Printf("decrypted: %s\n", plaintext)
}

Sealed boxes are ideal for one-way encrypted submissions: dropping a secret into a queue that only the owner of the private key can read.

Modern AEAD with ChaCha20-Poly1305

Newer libsodium code often prefers the IETF ChaCha20-Poly1305 AEAD (RFC 8439), which is also the cipher behind TLS 1.3 on platforms without AES hardware acceleration. Go exposes it through golang.org/x/crypto/chacha20poly1305, including the XChaCha20-Poly1305 variant with a 24-byte nonce that, like secretbox, is safe to generate randomly.

The AEAD interface also supports additional authenticated data (AAD): data that is authenticated but not encrypted, such as a header or message ID.

package main

import (
	"crypto/rand"
	"errors"
	"fmt"

	"golang.org/x/crypto/chacha20poly1305"
)

// encryptXChaCha encrypts with XChaCha20-Poly1305 and authenticates the
// additional data. Output is nonce||ciphertext.
func encryptXChaCha(plaintext, key, additionalData []byte) ([]byte, error) {
	aead, err := chacha20poly1305.NewX(key)
	if err != nil {
		return nil, err
	}

	nonce := make([]byte, aead.NonceSize())
	if _, err := rand.Read(nonce); err != nil {
		return nil, err
	}

	// Seal appends the ciphertext to the nonce prefix.
	return aead.Seal(nonce, nonce, plaintext, additionalData), nil
}

// decryptXChaCha reverses encryptXChaCha. The same additionalData must be
// supplied or authentication fails.
func decryptXChaCha(encrypted, key, additionalData []byte) ([]byte, error) {
	aead, err := chacha20poly1305.NewX(key)
	if err != nil {
		return nil, err
	}
	if len(encrypted) < aead.NonceSize() {
		return nil, errors.New("xchacha: message too short")
	}

	nonce := encrypted[:aead.NonceSize()]
	ciphertext := encrypted[aead.NonceSize():]

	return aead.Open(nil, nonce, ciphertext, additionalData)
}

func main() {
	key := make([]byte, chacha20poly1305.KeySize)
	if _, err := rand.Read(key); err != nil {
		panic(err)
	}

	aad := []byte("message-id:42")

	ciphertext, err := encryptXChaCha([]byte("secret payload"), key, aad)
	if err != nil {
		panic(err)
	}

	plaintext, err := decryptXChaCha(ciphertext, key, aad)
	if err != nil {
		panic(err)
	}

	fmt.Printf("decrypted: %s\n", plaintext)
}

If the AAD supplied at decryption does not match what was used at encryption, Open returns an error even when the ciphertext itself is intact. This binds context (headers, routing keys, versions) to the message.

Deriving Keys from a Master Secret

You rarely want to reuse one key for everything. Libsodium’s crypto_kdf derives many subkeys from a single master key. In Go, HKDF (RFC 5869) over BLAKE2b or SHA-256 gives you the same capability: one high-entropy master key in, many independent purpose-specific keys out.

package main

import (
	"crypto/rand"
	"crypto/sha256"
	"fmt"
	"io"

	"golang.org/x/crypto/hkdf"
)

// deriveKey derives a 32-byte subkey bound to a context label. Different
// labels produce independent keys from the same master secret.
func deriveKey(master []byte, context string) ([]byte, error) {
	// salt is optional for HKDF; the context info separates purposes.
	kdf := hkdf.New(sha256.New, master, nil, []byte(context))

	subkey := make([]byte, 32)
	if _, err := io.ReadFull(kdf, subkey); err != nil {
		return nil, err
	}
	return subkey, nil
}

func main() {
	master := make([]byte, 32)
	if _, err := rand.Read(master); err != nil {
		panic(err)
	}

	encKey, err := deriveKey(master, "encryption-v1")
	if err != nil {
		panic(err)
	}

	macKey, err := deriveKey(master, "authentication-v1")
	if err != nil {
		panic(err)
	}

	fmt.Printf("encryption key: %x\n", encKey)
	fmt.Printf("authentication key: %x\n", macKey)
}

Changing the context label (for example bumping v1 to v2) rotates a derived key without touching the master secret, which is a clean way to version your key schedule.

Password Hashing with Argon2id

Encryption keys should be random, but passwords are not. To turn a human password into a key, or to store password verifiers, libsodium uses Argon2id (RFC 9106), the winner of the Password Hashing Competition and a memory-hard function that resists GPU and ASIC attacks.

Go provides it directly via golang.org/x/crypto/argon2. Always use a unique random salt per password and store the parameters alongside the hash.

package main

import (
	"crypto/rand"
	"crypto/subtle"
	"fmt"

	"golang.org/x/crypto/argon2"
)

// Argon2id parameters. Tune time and memory to your hardware budget; these
// are reasonable interactive defaults (64 MiB, 1 pass, 4 lanes).
const (
	argonTime    = 1
	argonMemory  = 64 * 1024 // KiB
	argonThreads = 4
	argonKeyLen  = 32
	saltLen      = 16
)

// hashPassword returns a random salt and the derived 32-byte hash.
func hashPassword(password []byte) (salt, hash []byte, err error) {
	salt = make([]byte, saltLen)
	if _, err = rand.Read(salt); err != nil {
		return nil, nil, err
	}

	hash = argon2.IDKey(password, salt, argonTime, argonMemory, argonThreads, argonKeyLen)
	return salt, hash, nil
}

// verifyPassword recomputes the hash and compares in constant time.
func verifyPassword(password, salt, expected []byte) bool {
	computed := argon2.IDKey(password, salt, argonTime, argonMemory, argonThreads, argonKeyLen)
	return subtle.ConstantTimeCompare(computed, expected) == 1
}

func main() {
	salt, hash, err := hashPassword([]byte("correct horse battery staple"))
	if err != nil {
		panic(err)
	}

	fmt.Printf("valid password: %v\n", verifyPassword([]byte("correct horse battery staple"), salt, hash))
	fmt.Printf("wrong password: %v\n", verifyPassword([]byte("Tr0ub4dor&3"), salt, hash))
}

The subtle.ConstantTimeCompare call matters: comparing hashes with == or bytes.Equal can leak timing information about how many leading bytes matched. Always compare secret material in constant time.

Conclusion

Libsodium’s philosophy is that safe cryptography should be the easy path. The Go ecosystem delivers the same primitives natively through golang.org/x/crypto: authenticated encryption with secretbox, public-key messaging with box, anonymous submissions with sealed boxes, modern AEAD with ChaCha20-Poly1305, key derivation with HKDF, and password hashing with Argon2id. Reach for these misuse-resistant constructions and you get authentication, integrity, and confidentiality without assembling fragile primitives by hand, as covered for the lower-level AES modes in AES Encryption Modes: GCM, CBC, CTR, and More.