Crypto and Security

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18; every go.mod shown has the line go 1.27. It is a tour of what the standard library gives you for security work, in the order you usually need it: random numbers, hashing, message authentication, key derivation and password storage, authenticated encryption, signatures, the post-quantum packages that arrived between Go 1.24 and Go 1.27 (crypto/mlkem, crypto/hpke, crypto/mldsa), TLS and certificates, the FIPS 140-3 Go Cryptographic Module, vulnerability scanning with govulncheck, and the everyday secure coding practices (os.Root, html/template, SQL placeholders, request limits, keeping secrets out of logs). It is not a cryptography course: the rule of this page is to pick the highest-level API that does the job, never to compose primitives by hand, and to read the "watch out for" column before writing code.

It builds on Error Handling (every crypto function that can fail returns an error), Modules and Dependency Management (the checksum database and golang.org/x/crypto as a module) and Testing. The related pages are net/http Clients and Servers (servers, timeouts, CrossOriginProtection), Text and HTML Templates, database/sql (placeholders and SQL injection), Structured Logging with slog, I/O, Files and the Filesystem, Collections and Utility Packages (math/rand/v2), Command-Line Programs and Formatting, vet, fix and Static Analysis. Other pages on this site cover the same ground in other stacks and protocols: JWT and JOSE, OAuth security best practices, sender-constrained tokens, DPoP and mTLS, CORS, Node.js crypto, passwords and secrets, Axum password authentication and Axum TLS and HTTPS.

All examples were compiled with go vet and run on this machine unless the text says "not run". Random values and key material are different on every run, so those examples print lengths and booleans; everything that can be reproduced (hashes of fixed input, keys rebuilt from fixed seeds) prints real digests. Keys, salts and passwords in the examples are obviously fake. govulncheck and golang.org/x/crypto are not installed in the environment used to check this page; the parts that depend on them are described from their documentation and labelled "not run".

How Go approaches cryptography

  • One standard library, maintained by the Go team. the crypto package tree is part of the Go distribution and is covered by the security policy described in The security policy and release practice, with security fixes issued for the two most recent major releases.

  • High-level, hard-to-misuse APIs. crypto/tls and crypto/x509 choose algorithms and parameters for you, cipher.NewGCMWithRandomNonce removes nonce management, crypto/hpke bundles key agreement, key derivation and encryption, rand.Read cannot fail, and several functions simply ignore a caller-supplied randomness source (see Randomness).

  • Safer defaults change between releases. TLS 1.2 became the minimum in Go 1.22, RSA key exchange was removed from the default cipher suites in Go 1.22, 3DES in Go 1.23, SHA-1 certificate verification in Go 1.24 and SHA-1 TLS 1.2 signatures in Go 1.25, and hybrid post-quantum key exchange is on by default since Go 1.24. Each change ships with a GODEBUG setting that restores the old behaviour for a while; see Toolchain Versions and Compatibility for how GODEBUG defaults follow the go line in go.mod.

  • A frozen, validated module is available. The Go Cryptographic Module (FIPS 140-3) implements the approved algorithms, and GOFIPS140 selects a frozen version at build time (FIPS 140-3).

  • Extra algorithms live in golang.org/x/crypto. Password hashing (argon2, bcrypt, scrypt), chacha20poly1305, ssh, acme/autocert, nacl and others are in the x/crypto module (latest v0.58.0 on 2026-10-18), which has weaker compatibility promises than the standard library and needs go get.

Rule of thumb: if the standard library has a function named for your task (tls.Dial, hpke.Seal, hmac.Equal, rand.Text), use it instead of assembling one from block ciphers and hashes.

Choosing a primitive

Decision table mapping a security need to the Go standard library package to use

The figure maps a need to a package. The sections below follow it from the top. Two reminders apply to the whole page: a hash is not an encryption scheme, and an encryption scheme without authentication (ECB, CBC, CTR, CFB, OFB used on their own) is not a secure channel; use an AEAD such as AES-GCM, or let TLS or HPKE do it.

Randomness: crypto/rand

crypto/rand is the source of every secret: session tokens, API keys, salts, nonces, keys. It reads from the operating system’s CSPRNG (for example getrandom on Linux), so it needs no seeding. math/rand/v2 and the old math/rand are not a substitute even when seeded from the clock or ChaCha8: they are designed for simulation and shuffling, and their output can be predicted from a handful of observed values (see Collections and Utility Packages for the comparison).

API Behaviour Since

rand.Reader

An io.Reader backed by the OS CSPRNG; the variable can be replaced, but see the Go 1.26 notes below.

1.0

rand.Read(b)

Fills b with random bytes. It never returns an error: if the platform fails, the program crashes irrecoverably (Go 1.24).

1.0 (no-fail 1.24)

rand.Text()

A random string of 26 characters from the RFC 4648 base32 alphabet, at least 128 bits of entropy, for tokens, passwords and IDs. A future version may return longer texts.

1.24

rand.Int(r, max)

A uniform *big.Int in [0, max).

1.0

rand.Prime(r, bits)

A random prime of the given size; the r argument is ignored since Go 1.26.

1.0

package main

import (
	"crypto/rand"
	"fmt"
	"math/big"
	"strings"
)

func main() {
	// A 26-character base32 token with at least 128 bits of randomness.
	token := rand.Text()
	fmt.Println(len(token), strings.Trim(token, "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567") == "")

	// Raw bytes: Read never returns an error since Go 1.24.
	key := make([]byte, 32)
	n, err := rand.Read(key)
	fmt.Println(n, err)

	// A uniform integer in [0, 1000000), for example a one-time code.
	v, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
	if err != nil {
		panic(err)
	}
	fmt.Println(v.Cmp(big.NewInt(1_000_000)) < 0)
}
26 true
32 <nil>
true

Notes:

  • The 26 characters of rand.Text come from 26 random bytes reduced modulo 32, so the alphabet is a power of two and there is no modulo bias. If you need a different alphabet or length, do not take rand.Int modulo something yourself: read a larger buffer and reject out-of-range values, or build the token from rand.Text pieces.

  • A token is a secret: compare it in constant time (next section) and never log it.

  • Go 1.26 ignores the randomness argument. rsa.GenerateKey, ecdsa.GenerateKey, ecdsa.SignASN1, ecdh.Curve.GenerateKey, dsa.GenerateKey and rand.Prime now always use a secure internal source, whatever io.Reader you pass. Code that passed a deterministic reader to get repeatable keys in tests must change. The temporary escape hatch is GODEBUG=cryptocustomrand=1; the supported route is testing/cryptotest.SetGlobalRandom.

  • ed25519.GenerateKey(nil) also uses the secure source (Go 1.26), so passing nil is fine.

  • In FIPS 140-3 mode rand.Reader is a NIST SP 800-90A DRBG, with the platform CSPRNG mixed in at every Read.

Deterministic randomness in tests

cryptotest.SetGlobalRandom(t, seed) (Go 1.26) replaces the randomness used by crypto/rand and every implicit source in the crypto packages for the duration of the test, so a test of token issuing or key generation can assert exact values. It is test-only; the next example is a _test.go file.

// Package token issues opaque session tokens for the shelf service.
package token

import "crypto/rand"

// New returns a new random session token.
func New() string { return rand.Text() }
package token

import (
	"testing"
	"testing/cryptotest"
)

func TestNewIsDeterministicUnderSetGlobalRandom(t *testing.T) {
	cryptotest.SetGlobalRandom(t, 1)
	first := New()
	t.Log("first token:", first)

	cryptotest.SetGlobalRandom(t, 1)
	if got := New(); got != first {
		t.Fatalf("same seed gave %q, want %q", got, first)
	}
	cryptotest.SetGlobalRandom(t, 2)
	if got := New(); got == first {
		t.Fatalf("different seed gave the same token %q", got)
	}
}
=== RUN   TestNewIsDeterministicUnderSetGlobalRandom
    token_test.go:11: first token: KGY7P5J3OYLTEOSH3FIOFZIUSB
--- PASS: TestNewIsDeterministicUnderSetGlobalRandom (0.00s)
PASS
ok  	example.com/shelf/e06	0.078s

Because the seed is a plain integer, the output above is reproducible on every machine; that is exactly why the function must never be used outside tests. Compare Testing for how t.Cleanup restores the previous source.

Hashing

A cryptographic hash maps arbitrary data to a fixed-size digest with preimage and collision resistance. Use it for integrity checks, content-addressed IDs and as a building block (HMAC, signatures); do not use a bare fast hash to store passwords.

Package Functions Notes

crypto/sha256

Sum256, Sum224, New, New224

SHA-2, the default choice. 32-byte digest.

crypto/sha512

Sum512, Sum384, Sum512_256, New, New384

SHA-2 with 64-bit words; often faster than SHA-256 on 64-bit CPUs for large inputs.

crypto/sha3

Sum224, Sum256, Sum384, Sum512, New256, SumSHAKE128, SumSHAKE256, NewSHAKE256, NewCSHAKE256

SHA-3 and SHAKE extendable-output functions (FIPS 202). New in Go 1.24, moved from x/crypto/sha3; Clone (1.25); the zero value is a usable SHA3-256 or SHAKE256 (1.26).

crypto/md5, crypto/sha1

Sum, New

Broken for security. Fine for non-adversarial checksums and legacy protocols only. Rejected in GODEBUG=fips140=only mode.

hash/crc32, hash/fnv, hash/maphash

Non-cryptographic: hash tables, error detection. Never for security.

package main

import (
	"crypto/sha256"
	"crypto/sha3"
	"encoding/hex"
	"fmt"
	"io"
	"strings"
)

func main() {
	msg := []byte("978-0-13-419044-0")

	s2 := sha256.Sum256(msg)
	fmt.Println("sha256  ", hex.EncodeToString(s2[:]))

	s3 := sha3.Sum256(msg)
	fmt.Println("sha3-256", hex.EncodeToString(s3[:]))

	// The classic test vector: SHA-256("abc").
	abc := sha256.Sum256([]byte("abc"))
	fmt.Printf("abc     %x\n", abc)

	// Streaming: hash.Hash works with io.Copy and io.MultiWriter.
	h := sha256.New()
	if _, err := io.Copy(h, strings.NewReader("978-0-13-419044-0")); err != nil {
		panic(err)
	}
	fmt.Println("stream  ", hex.EncodeToString(h.Sum(nil)) == hex.EncodeToString(s2[:]))

	// SHAKE256 is an extendable-output function: choose any length.
	fmt.Printf("shake256 %x\n", sha3.SumSHAKE256(msg, 16))

	// The zero value of sha3.SHA3 is SHA3-256 (Go 1.26).
	var z sha3.SHA3
	z.Write(msg)
	fmt.Println("zero    ", hex.EncodeToString(z.Sum(nil)) == hex.EncodeToString(s3[:]))
}
sha256   de88d72465a90fa5cae1c9a3a60fe1869ef51fd2418f63cadbb9bac63665bc12
sha3-256 82499abe207b9a8ee4455ff11b5596f119a4886c6e990dc6b4cf6c132ade7cfb
abc     ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
stream   true
shake256 c1788b7f72664721dceb455c4981465b
zero     true

The value printed for abc is the published SHA-256 test vector, which is a cheap way to confirm that a build behaves as expected. Notes:

  • The one-shot Sum256 functions return arrays ([32]byte) and do not allocate; convert with sum[:] to get a slice. Printing an array with %x works without slicing.

  • hash.Hash implementations are not safe for concurrent use, and since Go 1.24 the standard ones also implement encoding.BinaryAppender, so their state can be saved and restored.

  • SHA-2 is vulnerable to length extension, so H(secret || message) is not a MAC. Use HMAC (next section) or SHA-3.

  • SHAKE returns as many bytes as you ask for, which is useful as a key derivation or mask function; a 16-byte output has a collision resistance of 64 bits, so choose the length for the security level you need.

Message authentication and constant-time comparison

HMAC authenticates a message with a shared key: only someone who has the key can produce a valid tag. hmac.New(sha256.New, key) returns a hash.Hash; write the message, then Sum(nil). The check must use hmac.Equal, which compares in constant time, because an ordinary == or bytes.Equal stops at the first differing byte and leaks how many leading bytes of a forged tag were right.

package main

import (
	"bytes"
	"crypto/hmac"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"fmt"
)

// Obviously fake signing key for the example only.
var key = []byte("example-not-a-real-key-0123456789")

func sign(msg []byte) []byte {
	m := hmac.New(sha256.New, key)
	m.Write(msg)
	return m.Sum(nil)
}

func valid(msg, tag []byte) bool {
	return hmac.Equal(tag, sign(msg))
}

func main() {
	msg := []byte("loan=42&member=7&due=2026-11-01")
	tag := sign(msg)
	fmt.Println("tag   ", hex.EncodeToString(tag))
	fmt.Println("valid ", valid(msg, tag))
	fmt.Println("forged", valid([]byte("loan=42&member=7&due=2099-01-01"), tag))

	// Never compare secrets with bytes.Equal or ==: they stop at the first
	// differing byte. subtle.ConstantTimeCompare returns 1 or 0.
	fmt.Println(bytes.Equal(tag, tag), subtle.ConstantTimeCompare(tag, tag), subtle.ConstantTimeCompare(tag, tag[:16]))

	// Constant-time select and XOR helpers.
	fmt.Println(subtle.ConstantTimeSelect(1, 10, 20), subtle.ConstantTimeSelect(0, 10, 20))
	dst := make([]byte, 4)
	n := subtle.XORBytes(dst, []byte{0xff, 0x0f, 0xf0, 0x00}, []byte{0x0f, 0x0f, 0x0f, 0x0f})
	fmt.Println(n, dst)

	// WithDataIndependentTiming asks the CPU for data-independent timing
	// (arm64 DIT bit); on other architectures it just runs f.
	var ok int
	subtle.WithDataIndependentTiming(func() {
		ok = subtle.ConstantTimeCompare(tag, tag)
	})
	fmt.Println(ok)
}
tag    fdc71ba45a4d513ac47b3a5c6dd073977b4c1638bbfe45d8c62e47691a386e4b
valid  true
forged false
true 1 0
10 20
4 [240 0 255 15]
1

crypto/subtle is the home of the constant-time helpers:

Function Purpose

ConstantTimeCompare(x, y)

Returns 1 if the slices have equal length and contents, 0 otherwise; the time depends only on the length.

ConstantTimeEq, ConstantTimeByteEq, ConstantTimeLessOrEq

Compare integers without branching.

ConstantTimeSelect(v, x, y), ConstantTimeCopy(v, x, y)

Choose or copy based on a 0 or 1 selector without a branch.

XORBytes(dst, x, y)

XOR two slices (Go 1.20); the output must overlap an input exactly or not at all, otherwise it panics (Go 1.24).

WithDataIndependentTiming(f)

Runs f with the CPU’s data-independent timing mode switched on, where it exists (the arm64 DIT bit); elsewhere it just calls f (Go 1.24). Since Go 1.26 it no longer locks the goroutine to its OS thread and the mode is inherited by goroutines started inside f.

WithDataIndependentTiming does not make variable-time code constant time; it only stops the CPU’s own optimisations from making constant-time code variable. Use it around code that is already written with the helpers above.

Authenticating something is not the same as encrypting it, and a tag proves integrity only for the exact bytes that were signed: include everything that matters (the loan ID, the member, the due date) in the authenticated message, or an attacker can replay a valid tag on a different context. Add a version or purpose label to the message, as the shelf/v1 header does in the AES-GCM example below.

Key derivation and password storage

hkdf and pbkdf2

Two key derivation functions moved into the standard library in Go 1.24 (both were in x/crypto):

  • crypto/hkdf (RFC 5869) turns one high-entropy secret into any number of independent subkeys. hkdf.Key(h, secret, salt, info, length) does Extract and Expand in one call; hkdf.Extract and hkdf.Expand are the two steps. The info string names the purpose, so the encryption key and the MAC key derived from one master secret never coincide.

  • crypto/pbkdf2 (RFC 8018) stretches a low-entropy password with an iteration count. It is the only password-based KDF in the standard library and the one the FIPS 140-3 module approves.

Both take the hash as a function (sha256.New, not sha256.New()) and are generic over the hash type; passing a value instead of the constructor is a compile error:

package main

import (
	"crypto/hkdf"
	"crypto/sha256"
	"fmt"
)

func main() {
	key, err := hkdf.Key(sha256.New(), []byte("secret"), nil, "ctx", 32)
	fmt.Println(key, err)
}
# example.com/shelf/e19
e19/main.go:10:23: in call to hkdf.Key, type hash.Hash of sha256.New() does not match func() Hash (cannot infer Hash)

Source: crypto/hkdf

Fixed input, so the digests below are reproducible:

package main

import (
	"crypto/hkdf"
	"crypto/pbkdf2"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
)

func main() {
	// PBKDF2-HMAC-SHA-256 with a fixed (example-only) salt so the output is
	// reproducible. Real salts are random per password.
	salt := []byte("0123456789abcdef")
	dk, err := pbkdf2.Key(sha256.New, "correct horse battery staple", salt, 600_000, 32)
	if err != nil {
		panic(err)
	}
	fmt.Println("pbkdf2", hex.EncodeToString(dk))

	// HKDF: derive independent subkeys from one high-entropy secret.
	secret := []byte("example-master-secret-not-real-0123456789")
	enc, err := hkdf.Key(sha256.New, secret, []byte("salt-1"), "shelf encryption", 32)
	if err != nil {
		panic(err)
	}
	mac, err := hkdf.Key(sha256.New, secret, []byte("salt-1"), "shelf mac", 32)
	if err != nil {
		panic(err)
	}
	fmt.Println("enc", hex.EncodeToString(enc))
	fmt.Println("mac", hex.EncodeToString(mac))

	// The two steps separately (RFC 5869).
	prk, _ := hkdf.Extract(sha256.New, secret, []byte("salt-1"))
	okm, _ := hkdf.Expand(sha256.New, prk, "shelf encryption", 32)
	fmt.Println("same", hex.EncodeToString(okm) == hex.EncodeToString(enc))

	// Invalid use is an error, not a panic.
	_, err = hkdf.Key(sha256.New, secret, nil, "x", 255*32+1)
	fmt.Println(err)
}
pbkdf2 6c4a646aad10d067add5fb79d9078a16da83d50f81670a8e7593b249e6d94936
enc 86761ba1d290c459260ffce5fcda0ee6d6df8855be65d602c2bdfe416ecc3537
mac e4d111f013114d0654e8579c52dea1df48571e2be0a047c625c43e8b651a18bf
same true
hkdf: requested key length too large

Source: crypto/pbkdf2 and RFC 5869

Notes:

  • A password-derived key has the password’s entropy; HKDF alone must never be applied to a human-chosen password.

  • The iteration count is a cost knob: OWASP’s current guidance for PBKDF2-HMAC-SHA256 is 600,000 iterations, and the number should grow with hardware. Store the parameters next to the hash so you can raise them later.

  • In GODEBUG=fips140=only mode pbkdf2.Key rejects salts shorter than 128 bits, derived keys shorter than 112 bits and hashes other than SHA-2 or SHA-3; hkdf rejects short input keys (see the FIPS section).

Password hashing with x/crypto

Stored passwords should be hashed with a memory-hard function designed for the purpose. These live in golang.org/x/crypto, so add it with go get golang.org/x/crypto@v0.58.0. They are not installed here, so the code below is not run; it was written against the package documentation.

Package Recommended use Parameters and limits

argon2

New systems: argon2.IDKey (Argon2id, RFC 9106). If unsure, use it.

time, memory (KiB), threads. The package documents time=1, memory=2 GiB, threads=4 as the RFC’s first option and time=3, memory=64 MiB, threads=4 as the second; OWASP’s minimum is 19 MiB with time=2 and one thread.

bcrypt

Legacy systems. GenerateFromPassword, CompareHashAndPassword.

Cost 4 to 31, default 10 (use 10 or more). Passwords longer than 72 bytes are rejected with bcrypt.ErrPasswordTooLong.

scrypt

When Argon2 is not available. scrypt.Key.

The package documents N=32768, r=8, p=1 as the interactive recommendation as of 2017; raise N over time.

// Not run: needs golang.org/x/crypto, which is not available in this environment.
package auth

import (
	"crypto/rand"
	"crypto/subtle"
	"encoding/base64"
	"fmt"
	"strings"

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

const (
	memoryKiB = 19 * 1024 // OWASP minimum for time=2, threads=1
	passes    = 2
	threads   = 1
	keyLen    = 32
)

// Hash returns a self-describing string: parameters, salt and hash.
func Hash(password string) (string, error) {
	salt := make([]byte, 16)
	rand.Read(salt)
	key := argon2.IDKey([]byte(password), salt, passes, memoryKiB, threads, keyLen)
	return fmt.Sprintf("argon2id:%d:%d:%d:%s:%s", memoryKiB, passes, threads,
		base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
}

// Verify recomputes the hash with the stored parameters and compares in constant time.
func Verify(password, encoded string) bool {
	var m, t, p uint32
	parts := strings.Split(encoded, ":")
	if len(parts) != 6 || parts[0] != "argon2id" {
		return false
	}
	if _, err := fmt.Sscanf(strings.Join(parts[1:4], " "), "%d %d %d", &m, &t, &p); err != nil {
		return false
	}
	salt, err1 := base64.RawStdEncoding.DecodeString(parts[4])
	want, err2 := base64.RawStdEncoding.DecodeString(parts[5])
	if err1 != nil || err2 != nil {
		return false
	}
	got := argon2.IDKey([]byte(password), salt, t, m, uint8(p), uint32(len(want)))
	return subtle.ConstantTimeCompare(got, want) == 1
}

The pattern is always the same: a fresh random salt per password, parameters stored with the hash, verification with a constant-time comparison, and a policy to re-hash on login when the stored parameters are weaker than the current ones. bcrypt packs all of that into one string, which is why it is easy to use correctly; its 72-byte limit is the reason to prefer Argon2id for new code. A global "pepper" (a secret mixed into every hash and kept outside the database) is an optional extra, not a replacement for any of this. The authentication side of the story (sessions, tokens, MFA) is covered on this site in Authentication methods, 2FA and passwordless.

Authenticated encryption

Encrypting data with a shared key needs an AEAD (authenticated encryption with associated data): it hides the plaintext and detects any modification of the ciphertext or of the associated data. In the standard library that is cipher.AEAD, usually AES-GCM; golang.org/x/crypto/chacha20poly1305 is the usual alternative on hardware without AES instructions.

The classic pitfall is the nonce: AES-GCM with the same key and nonce twice leaks the plaintext relationship and the authentication key. Since Go 1.24 cipher.NewGCMWithRandomNonce removes the problem for ordinary volumes of data: it generates a random 12-byte nonce in Seal and prepends it to the ciphertext, and Open reads it back. Pass nil as the nonce argument.

package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"fmt"
)

func main() {
	key := make([]byte, 32) // AES-256; a real key comes from a KMS or HKDF
	rand.Read(key)

	block, err := aes.NewCipher(key)
	if err != nil {
		panic(err)
	}
	// Go 1.24: the AEAD generates a random nonce and prepends it.
	aead, err := cipher.NewGCMWithRandomNonce(block)
	if err != nil {
		panic(err)
	}

	plain := []byte("member 7 borrowed 978-0-13-419044-0")
	header := []byte("shelf/v1")
	sealed := aead.Seal(nil, nil, plain, header)
	fmt.Println(len(plain), len(sealed), aead.NonceSize(), aead.Overhead())

	got, err := aead.Open(nil, nil, sealed, header)
	fmt.Println(string(got), err)

	// Tampering with a single bit, or with the associated data, fails.
	sealed[len(sealed)-1] ^= 1
	_, err = aead.Open(nil, nil, sealed, header)
	fmt.Println(err)
	sealed[len(sealed)-1] ^= 1
	_, err = aead.Open(nil, nil, sealed, []byte("shelf/v2"))
	fmt.Println(err)

	// A wrong key size is an error from aes.NewCipher.
	_, err = aes.NewCipher(make([]byte, 20))
	fmt.Println(err)
}
35 63 0 28
member 7 borrowed 978-0-13-419044-0 <nil>
cipher: message authentication failed
cipher: message authentication failed
crypto/aes: invalid key size 20

The output shows the arithmetic: the 35-byte message grows to 63 bytes (12-byte nonce plus 16-byte tag, Overhead() is 28), NonceSize() is 0 because the caller supplies no nonce, flipping one bit fails with cipher: message authentication failed, and so does opening with different associated data. Notes:

  • A random 96-bit nonce is safe for up to about four billion (2 to the power 32) messages per key; beyond that, rotate the key or use a counter-based nonce with NewGCM.

  • The key must be 16, 24 or 32 bytes (AES-128, 192, 256), as the last line of output shows.

  • cipher.NewCBCEncrypter, NewCTR, NewOFB, NewCFBEncrypter and NewCFBDecrypter give confidentiality without authentication; OFB and CFB were deprecated in Go 1.24. If you must use CTR or CBC, authenticate the ciphertext with HMAC (encrypt-then-MAC).

  • Keys come from a KMS, an environment-provided secret passed through HKDF, or crypto/rand; never from a string literal.

  • For encrypting to someone’s public key, use HPKE (below), not raw RSA encryption.

Signatures and public-key algorithms

Package Use Notes

crypto/ed25519

Default signature scheme: 32-byte keys, 64-byte signatures, deterministic, fast.

NewKeyFromSeed rebuilds a key from its 32-byte seed. Verify and VerifyWithOptions.

crypto/ecdsa

When a protocol requires NIST curves (P-256, P-384, P-521).

SignASN1 and VerifyASN1; raw encodings ParseRawPrivateKey, PrivateKey.Bytes (1.25). The big.Int fields are deprecated (1.26).

crypto/rsa

Legacy interoperability. Use PSS for signatures and OAEP for encryption.

Keys under 1024 bits are rejected since Go 1.24 (rsa1024min); prefer 2048 or more. DecryptPKCS1v15 is deprecated (1.26). EncryptOAEPWithOptions (1.26).

crypto/ecdh

Key agreement (X25519, P-256, P-384, P-521).

KeyExchanger interface (1.26) for hardware-backed keys.

crypto/mldsa

Post-quantum signatures (below).

Go 1.27.

crypto.Signer is the interface that unifies private keys (including those held in an HSM or KMS); crypto.MessageSigner (1.25) is its variant for signers that hash the message themselves, and x509.CreateCertificate accepts either.

Small keys fail loudly instead of silently weakening:

package main

import (
	"crypto/pbkdf2"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"fmt"
)

func main() {
	_, err := rsa.GenerateKey(rand.Reader, 512)
	fmt.Println(err)

	// An 8-byte salt is accepted normally but rejected in FIPS 140-only mode.
	_, err = pbkdf2.Key(sha256.New, "example-password", []byte("saltsalt"), 1000, 32)
	fmt.Println(err)
}
crypto/rsa: 512-bit keys are insecure (see https://go.dev/pkg/crypto/rsa#hdr-Minimum_key_size)
<nil>

Post-quantum and modern primitives

A large enough quantum computer would break RSA, finite-field Diffie-Hellman and elliptic-curve cryptography. Encrypted traffic recorded today can be decrypted later ("harvest now, decrypt later"), so key exchange is migrated first; signatures follow. NIST standardised ML-KEM (FIPS 203) for key encapsulation and ML-DSA (FIPS 204) for signatures, and Go added them in three steps. The release notes agree with the outline of this section:

Package Added What it is

crypto/mlkem

Go 1.24

ML-KEM-768 and ML-KEM-1024 (FIPS 203). Go 1.26 added Encapsulator() methods and the crypto/mlkem/mlkemtest package for known-answer tests.

crypto/hkdf, crypto/pbkdf2, crypto/sha3

Go 1.24

Promoted from x/crypto.

crypto/hpke

Go 1.26

Hybrid Public Key Encryption (RFC 9180), including post-quantum hybrid KEMs. Also in 1.26: crypto.Encapsulator, crypto.Decapsulator, ecdh.KeyExchanger.

crypto/mldsa

Go 1.27

ML-DSA-44, -65 and -87 (FIPS 204). X.509 and TLS 1.3 gained ML-DSA support at the same time.

ML-KEM: crypto/mlkem

A KEM (key encapsulation mechanism) is the post-quantum replacement for Diffie-Hellman: the holder of a decapsulation key publishes the encapsulation key; a sender calls Encapsulate to get a fresh shared key plus a ciphertext; the holder calls Decapsulate on the ciphertext to recover the same shared key. The package documentation recommends ML-KEM-768 (DecapsulationKey768, EncapsulationKey768); ML-KEM-1024 has the same API with a 1024 suffix.

package main

import (
	"bytes"
	"crypto/mlkem"
	"crypto/sha256"
	"fmt"
)

func main() {
	// Receiver: generate a key pair and publish the encapsulation key.
	dk, err := mlkem.GenerateKey768()
	if err != nil {
		panic(err)
	}
	ek := dk.EncapsulationKey()
	pub := ek.Bytes()

	// Sender: parse the public key, derive a shared key and a ciphertext.
	peer, err := mlkem.NewEncapsulationKey768(pub)
	if err != nil {
		panic(err)
	}
	sharedA, ciphertext := peer.Encapsulate()

	// Receiver: recover the same shared key from the ciphertext.
	sharedB, err := dk.Decapsulate(ciphertext)
	if err != nil {
		panic(err)
	}
	fmt.Println(len(pub), len(ciphertext), len(sharedA), mlkem.SharedKeySize)
	fmt.Println(bytes.Equal(sharedA, sharedB))

	// A key is fully determined by its 64-byte seed, so it can be stored
	// compactly and rebuilt (the seed is the secret; keep it safe).
	seed := bytes.Repeat([]byte{0x2a}, mlkem.SeedSize)
	k1, _ := mlkem.NewDecapsulationKey768(seed)
	k2, _ := mlkem.NewDecapsulationKey768(seed)
	fmt.Printf("%x\n", sha256.Sum256(k1.EncapsulationKey().Bytes()))
	fmt.Println(bytes.Equal(k1.Bytes(), k2.Bytes()), len(k1.Bytes()))

	// ML-KEM decapsulation never fails on a well-sized ciphertext
	// (implicit rejection), but a wrong-sized one is an error.
	_, err = dk.Decapsulate(ciphertext[:10])
	fmt.Println(err)
	junk := make([]byte, len(ciphertext))
	wrong, err := dk.Decapsulate(junk)
	fmt.Println(err, bytes.Equal(wrong, sharedA))
}
1184 1088 32 32
true
61478b1d7a03527d8a2fa89ff7de964779a8d8a83492060282da375068dec694
true 64
mlkem: invalid ciphertext length
<nil> false

The shared key is 32 bytes and is meant to feed a KDF and then an AEAD, not to be used directly as a ciphertext key without that step. The encapsulation key is 1184 bytes and the ciphertext 1088 bytes: far bigger than X25519’s 32 bytes, which is why TLS uses a hybrid exchange (below). The hash printed for the key built from a fixed seed is stable because ML-KEM keys are derived deterministically from their 64-byte seed (NewDecapsulationKey768(seed)); store the seed, not an expanded key. Decapsulating a ciphertext of the right length but made up of zeros does not return an error (ML-KEM’s implicit rejection returns a pseudo-random key instead), so the application finds out only when the derived keys do not match; a ciphertext of the wrong length is rejected with an error.

HPKE: crypto/hpke

HPKE (RFC 9180) is "public-key encryption done properly": a KEM to agree on a secret, a KDF to derive keys and an AEAD to encrypt, packaged as one operation. Use it whenever you would otherwise reach for RSA encryption or invent an ECDH plus AES protocol, for example to encrypt an export for a recipient who published a public key. A suite is chosen from the constructors: KEMs DHKEM(curve), MLKEM768(), MLKEM1024(), and the hybrids MLKEM768X25519(), MLKEM768P256() and MLKEM1024P384(); KDFs HKDFSHA256(), HKDFSHA384(), HKDFSHA512(), SHAKE128(), SHAKE256(); AEADs AES128GCM(), AES256GCM(), ChaCha20Poly1305() and ExportOnly().

package main

import (
	"crypto/ecdh"
	"crypto/hpke"
	"fmt"
)

func main() {
	// Suite: hybrid post-quantum KEM + HKDF-SHA256 + ChaCha20-Poly1305.
	kem, kdf, aead := hpke.MLKEM768X25519(), hpke.HKDFSHA256(), hpke.ChaCha20Poly1305()
	fmt.Printf("kem=0x%04x kdf=0x%04x aead=0x%04x\n", kem.ID(), kdf.ID(), aead.ID())

	// The recipient owns the private key and publishes the public key.
	priv, err := kem.GenerateKey()
	if err != nil {
		panic(err)
	}
	pub := priv.PublicKey()
	fmt.Println("public key bytes:", len(pub.Bytes()))

	// One-shot API: the result is enc || ciphertext.
	info := []byte("shelf export v1")
	sealed, err := hpke.Seal(pub, kdf, aead, info, []byte("overdue list"))
	if err != nil {
		panic(err)
	}
	plain, err := hpke.Open(priv, kdf, aead, info, sealed)
	fmt.Printf("%d bytes sealed, opened %q, err=%v\n", len(sealed), plain, err)

	// A different info string derives different keys, so Open fails.
	_, err = hpke.Open(priv, kdf, aead, []byte("other app"), sealed)
	fmt.Println("wrong info:", err != nil)

	// Streaming API: one encapsulation, many messages with associated data.
	enc, sender, err := hpke.NewSender(pub, kdf, aead, info)
	if err != nil {
		panic(err)
	}
	recipient, err := hpke.NewRecipient(enc, priv, kdf, aead, info)
	if err != nil {
		panic(err)
	}
	for _, m := range []string{"first", "second"} {
		ct, err := sender.Seal([]byte("loan-42"), []byte(m))
		if err != nil {
			panic(err)
		}
		pt, err := recipient.Open([]byte("loan-42"), ct)
		fmt.Printf("%s %v\n", pt, err)
	}

	// Both ends can derive the same exported secret.
	a, _ := sender.Export("shelf/token", 16)
	b, _ := recipient.Export("shelf/token", 16)
	fmt.Println(string(a) == string(b), len(a))

	// The classic DHKEM from RFC 9180 is also available.
	fmt.Printf("dhkem x25519 id=0x%04x\n", hpke.DHKEM(ecdh.X25519()).ID())
}
kem=0x647a kdf=0x0001 aead=0x0003
public key bytes: 1216
1148 bytes sealed, opened "overdue list", err=<nil>
wrong info: true
first <nil>
second <nil>
true 16
dhkem x25519 id=0x0020

Source: crypto/hpke and RFC 9180

hpke.Seal and hpke.Open are the one-shot API: the result is the encapsulated key followed by the ciphertext (1120 bytes of encapsulated key plus 28 bytes for the 12-byte message and its 16-byte tag give the 1148 bytes shown). The info string is bound into the key schedule, so a message sealed for one application context cannot be opened in another. NewSender and NewRecipient are the streaming API: one encapsulation, a nonce counter incremented per Seal and Open, and associated data per message (here the loan ID); messages must be opened in order. Export derives extra secrets from the same context.

ML-DSA: crypto/mldsa

crypto/mldsa implements the lattice-based signature scheme of FIPS 204 with three parameter sets (MLDSA44(), MLDSA65(), MLDSA87()); the package documentation says most applications should use MLDSA44. A private key is a 32-byte seed (PrivateKeySize); the public key is 1312 bytes and a signature 2420 bytes for MLDSA44, far larger than Ed25519’s 32 and 64. PrivateKey implements crypto.Signer, so it plugs into code that signs through that interface.

package main

import (
	"bytes"
	"crypto/ed25519"
	"crypto/mldsa"
	"crypto/sha256"
	"fmt"
)

func main() {
	msg := []byte("catalogue snapshot 2026-10-18")

	// ML-DSA (FIPS 204, Go 1.27). MLDSA44 is the parameter set the
	// documentation recommends for most applications.
	sk, err := mldsa.GenerateKey(mldsa.MLDSA44())
	if err != nil {
		panic(err)
	}
	pk := sk.PublicKey()
	sig, err := sk.Sign(nil, msg, nil) // hedged signing; the reader is ignored
	if err != nil {
		panic(err)
	}
	fmt.Println(len(sk.Bytes()), len(pk.Bytes()), len(sig), mldsa.MLDSA44().SignatureSize())
	fmt.Println("verify:", mldsa.Verify(pk, msg, sig, nil))
	fmt.Println("tampered:", mldsa.Verify(pk, []byte("catalogue snapshot 2099"), sig, nil))

	// A context string separates signatures made for different purposes.
	ctx := &mldsa.Options{Context: "shelf/snapshot"}
	sig2, _ := sk.Sign(nil, msg, ctx)
	fmt.Println("wrong context:", mldsa.Verify(pk, msg, sig2, nil))
	fmt.Println("right context:", mldsa.Verify(pk, msg, sig2, ctx))

	// The private key is a 32-byte seed: rebuild it, and with
	// SignDeterministic get a reproducible signature.
	seed := bytes.Repeat([]byte{7}, mldsa.PrivateKeySize)
	k, _ := mldsa.NewPrivateKey(mldsa.MLDSA44(), seed)
	d1, _ := k.SignDeterministic(msg, nil)
	d2, _ := k.SignDeterministic(msg, nil)
	fmt.Printf("%x\n", sha256.Sum256(k.PublicKey().Bytes()))
	fmt.Printf("%x\n", sha256.Sum256(d1))
	fmt.Println(bytes.Equal(d1, d2))

	// Ed25519 for comparison: classical, tiny and deterministic.
	edSeed := bytes.Repeat([]byte{9}, ed25519.SeedSize)
	edKey := ed25519.NewKeyFromSeed(edSeed)
	edSig := ed25519.Sign(edKey, msg)
	fmt.Println(len(edKey.Public().(ed25519.PublicKey)), len(edSig), ed25519.Verify(edKey.Public().(ed25519.PublicKey), msg, edSig))
	fmt.Printf("%x\n", sha256.Sum256(edSig))
}
32 1312 2420 2420
verify: <nil>
tampered: mldsa: invalid signature
wrong context: mldsa: invalid signature
right context: <nil>
86c769d3b468de487eb9a09f242f7fa0b997a07ef4ded49627e7bf9539f28b62
a2ba7afa7ca79e5d14ed31654d35b9de88e592994c1bc822fc6f25e4f20c9ef2
true
32 64 true
bbc109d8712b631ded3b00c6fe652aa9b5d0ddbc2fa0b3f7b48c90e632dbdf44

Sign produces a hedged signature (fresh randomness; the reader argument is ignored), SignDeterministic always returns the same signature for the same key and message, which the hash of d1 and the true above confirm. Options.Context (up to 255 bytes) domain-separates signatures made for different purposes: a signature made with the context shelf/snapshot fails to verify with the empty context. The Go 1.27 release notes add crypto.MLDSAMu for external-mu signing and the x509.MLDSA44, MLDSA65 and MLDSA87 signature algorithms plus tls.MLDSA44, MLDSA65 and MLDSA87 signature schemes for TLS 1.3. The same Ed25519 sketch at the end shows the classical comparison.

Two cautions about the newest packages. The algorithms are standardised but young: new deployments usually combine them with a classical algorithm (hybrid) rather than replace it, which is what TLS does. And availability depends on the FIPS module you build against: crypto/mldsa is not part of Go Cryptographic Module v1.0.0 (its functions return an error there) and is available from v1.26.0.

TLS

crypto/tls implements TLS 1.2 and TLS 1.3 (RFC 8446). Using it through net/http (http.Client, httptest.NewTLSServer, Server.ListenAndServeTLS) covers most programs; the tls.Config below it is where policy lives. Defaults worth knowing:

Setting Default Since

Minimum version

TLS 1.2 for clients and servers (MinVersion zero value); TLS 1.0 is the lowest the package can speak.

1.22

TLS 1.3 cipher suites

Not configurable: AES-GCM and ChaCha20-Poly1305 suites are always used when TLS 1.3 is negotiated.

1.12

TLS 1.2 cipher suites

RSA key exchange removed from the defaults (1.22), 3DES removed (1.23); tls.InsecureCipherSuites() lists what is still available explicitly. The tlsrsakex, tls3des and tls10server settings were deleted in Go 1.27.

1.22, 1.23

Key exchange

X25519MLKEM768 hybrid post-quantum key exchange enabled by default (setting GODEBUG=tlsmlkem=0 reverts); SecP256r1MLKEM768 and SecP384r1MLKEM1024 added to the default set (tlssecpmlkem=0 reverts). MLKEM1024 can be enabled explicitly in CurvePreferences.

1.24, 1.26, 1.27

Ordering of CurvePreferences

Ignored; the package keeps the order and uses the slice only to decide which exchanges are allowed.

1.24

Signature algorithms

SHA-1 signatures disallowed in TLS 1.2 (tlssha1=1 re-enables); ML-DSA available in TLS 1.3.

1.25, 1.27

Server version choice

The server prefers the highest supported version, even if it is not the client’s first preference.

1.25

Encrypted Client Hello

Server support via Config.EncryptedClientHelloKeys and GetEncryptedClientHelloKeys.

1.24, 1.25

Randomness

Config.Rand is deprecated; use testing/cryptotest.SetGlobalRandom for deterministic tests.

1.27

In the example below an in-process server uses a freshly created CA and a leaf certificate for 127.0.0.1 (with x509.CreateCertificate; everything lives in memory and nothing is written to disk). The client negotiates TLS 1.3 with the default hybrid exchange, then is restricted to a classical group, then is refused when it only speaks TLS 1.1, when the CA is not trusted and when the name does not match.

package main

import (
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/tls"
	"crypto/x509"
	"crypto/x509/pkix"
	"errors"
	"fmt"
	"io"
	"log"
	"math/big"
	"net"
	"net/http"
	"net/http/httptest"
	"net/url"
	"time"
)

// newCA creates a throwaway root CA and a leaf certificate for 127.0.0.1.
func newCA() (*x509.Certificate, tls.Certificate, error) {
	caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return nil, tls.Certificate{}, err
	}
	caTmpl := &x509.Certificate{
		SerialNumber:          big.NewInt(1),
		Subject:               pkix.Name{CommonName: "shelf test CA"},
		NotBefore:             time.Now().Add(-time.Hour),
		NotAfter:              time.Now().Add(24 * time.Hour),
		IsCA:                  true,
		BasicConstraintsValid: true,
		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
	}
	caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey)
	if err != nil {
		return nil, tls.Certificate{}, err
	}
	ca, err := x509.ParseCertificate(caDER)
	if err != nil {
		return nil, tls.Certificate{}, err
	}

	leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return nil, tls.Certificate{}, err
	}
	leafTmpl := &x509.Certificate{
		SerialNumber: big.NewInt(2),
		Subject:      pkix.Name{CommonName: "shelfd"},
		DNSNames:     []string{"shelfd.test"},
		IPAddresses:  []net.IP{net.IPv4(127, 0, 0, 1)},
		NotBefore:    time.Now().Add(-time.Hour),
		NotAfter:     time.Now().Add(24 * time.Hour),
		KeyUsage:     x509.KeyUsageDigitalSignature,
		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
	}
	leafDER, err := x509.CreateCertificate(rand.Reader, leafTmpl, ca, &leafKey.PublicKey, caKey)
	if err != nil {
		return nil, tls.Certificate{}, err
	}
	return ca, tls.Certificate{Certificate: [][]byte{leafDER}, PrivateKey: leafKey}, nil
}

func main() {
	ca, leaf, err := newCA()
	if err != nil {
		panic(err)
	}

	srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "ok")
	}))
	srv.TLS = &tls.Config{Certificates: []tls.Certificate{leaf}}
	srv.Config.ErrorLog = log.New(io.Discard, "", 0) // hide expected handshake failures
	srv.StartTLS()
	defer srv.Close()

	pool := x509.NewCertPool()
	pool.AddCert(ca)

	// 1. Defaults: TLS 1.3 and a hybrid post-quantum key exchange.
	client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}}}
	resp, err := client.Get(srv.URL)
	if err != nil {
		panic(err)
	}
	st := resp.TLS
	resp.Body.Close()
	fmt.Println(tls.VersionName(st.Version), tls.CipherSuiteName(st.CipherSuite), st.CurveID)

	// 2. Restrict the curve preferences to a classical group.
	classic := &tls.Config{RootCAs: pool, CurvePreferences: []tls.CurveID{tls.X25519}}
	client = &http.Client{Transport: &http.Transport{TLSClientConfig: classic}}
	resp, err = client.Get(srv.URL)
	if err != nil {
		panic(err)
	}
	fmt.Println(resp.TLS.CurveID)
	resp.Body.Close()

	// 3. A client that only speaks TLS 1.1 cannot connect: Go's floor is 1.2.
	old := &tls.Config{RootCAs: pool, MaxVersion: tls.VersionTLS11}
	_, err = (&http.Client{Transport: &http.Transport{TLSClientConfig: old}}).Get(srv.URL)
	fmt.Println(err != nil)

	// 4. Without the CA in the pool, verification fails.
	empty := &tls.Config{RootCAs: x509.NewCertPool()}
	_, err = (&http.Client{Transport: &http.Transport{TLSClientConfig: empty}}).Get(srv.URL)
	fmt.Println(cause(err))

	// 5. The right CA but the wrong name.
	wrong := &tls.Config{RootCAs: pool, ServerName: "other.test"}
	_, err = (&http.Client{Transport: &http.Transport{TLSClientConfig: wrong}}).Get(srv.URL)
	fmt.Println(cause(err))

	// 6. Verify the chain directly with x509.
	leafCert, _ := x509.ParseCertificate(leaf.Certificate[0])
	chains, err := leafCert.Verify(x509.VerifyOptions{Roots: pool, DNSName: "shelfd.test"})
	fmt.Println(len(chains), len(chains[0]), err)
	_, err = leafCert.Verify(x509.VerifyOptions{Roots: pool, DNSName: "shelfd.test", CurrentTime: time.Now().Add(48 * time.Hour)})
	var invalid x509.CertificateInvalidError
	fmt.Println(errors.As(err, &invalid), invalid.Reason == x509.Expired)
}

// cause strips the request URL (it contains a random port) from a client error.
func cause(err error) error {
	var ue *url.Error
	if errors.As(err, &ue) {
		return ue.Err
	}
	return err
}
TLS 1.3 TLS_AES_128_GCM_SHA256 X25519MLKEM768
X25519
true
tls: failed to verify certificate: x509: certificate signed by unknown authority
tls: failed to verify certificate: x509: certificate is valid for shelfd.test, not other.test
1 2 <nil>
true true

What the output shows:

  • The first line is the negotiated protocol, cipher suite and ConnectionState.CurveID (Go 1.25); with the default configuration the key exchange is X25519MLKEM768, a concatenation of X25519 and ML-KEM-768 that stays secure as long as either component holds. The ciphertext is larger (about 1 KB in each direction), which is why a very small number of middleboxes broke on it, hence the tlsmlkem=0 escape hatch documented in GODEBUG.

  • Restricting CurvePreferences to tls.X25519 changes the negotiated exchange; that is also how to opt out per connection without a global setting. You can only restrict, not reorder.

  • Go does not speak TLS below 1.2 by default, so the TLS 1.1 client cannot connect.

  • Verification errors are typed: x509.UnknownAuthorityError, x509.HostnameError and x509.CertificateInvalidError (with Reason, for example x509.Expired). Test with errors.As, never by matching text.

  • The server’s handshake failures are logged on the ErrorLog of the http.Server; the example discards them to keep the output stable.

Configuration advice

  • Never set InsecureSkipVerify: true outside a test, and then only with the server in the same process. It accepts any certificate for any name. If you need a private CA, put it in RootCAs (x509.NewCertPool, AppendCertsFromPEM) instead.

  • Leave MinVersion alone unless you control all clients; set tls.VersionTLS13 for internal services where you do.

  • Do not list cipher suites unless a policy requires it: the defaults are ordered and filtered by the package, and a hand-written list gets stale. Hardware-independent ordering is automatic.

  • Mutual TLS (client certificates) is a ClientAuth and a ClientCAs pool on the server and Certificates on the client; sender-constrained tokens, DPoP and mTLS explains where it fits in OAuth.

  • Load certificate and key paths from configuration, never from source, and reload them with GetCertificate or GetConfigForClient when they rotate.

  • tls.Config must not be copied after use; clone it with Clone().

A server configuration with optional mutual TLS, written as a small function; it was compiled and run once with missing files to see the real error:

package main

import (
	"crypto/tls"
	"crypto/x509"
	"log"
	"net/http"
	"os"
	"time"
)

// serve starts shelfd with a hardened TLS configuration.
// certFile and keyFile come from the deployment, never from source code.
func serve(addr, certFile, keyFile, clientCAFile string) error {
	cfg := &tls.Config{
		MinVersion: tls.VersionTLS13, // only when every client supports it
	}

	if clientCAFile != "" { // optional mutual TLS
		pem, err := os.ReadFile(clientCAFile)
		if err != nil {
			return err
		}
		pool := x509.NewCertPool()
		if !pool.AppendCertsFromPEM(pem) {
			log.Fatal("no certificates in client CA file")
		}
		cfg.ClientCAs = pool
		cfg.ClientAuth = tls.RequireAndVerifyClientCert
	}

	srv := &http.Server{
		Addr:              addr,
		TLSConfig:         cfg,
		ReadHeaderTimeout: 5 * time.Second,
	}
	return srv.ListenAndServeTLS(certFile, keyFile)
}

func main() {
	log.Fatal(serve(":8443", os.Getenv("SHELFD_CERT"), os.Getenv("SHELFD_KEY"), os.Getenv("SHELFD_CLIENT_CA")))
}
2026/10/18 02:22:10 open /nonexistent: no such file or directory

crypto/x509 and certificates

crypto/x509 parses and creates certificates, certificate requests and revocation lists, marshals keys (MarshalPKCS8PrivateKey, ParsePKIXPublicKey, …​) and verifies chains. The pieces you use most:

  • x509.NewCertPool(), AppendCertsFromPEM and SystemCertPool() build the set of trusted roots. On macOS and Windows, verification of the system roots is done by platform APIs; since Go 1.27, SystemCertPool honours SSL_CERT_FILE and SSL_CERT_DIR on those systems too and then uses Go’s own verifier (GODEBUG=x509sslcertoverrideplatform=0 disables that).

  • Certificate.Verify(x509.VerifyOptions{Roots, Intermediates, DNSName, KeyUsages, CurrentTime}) returns the verified chains; VerifyHostname checks a name only. The last two lines of the example above verify a leaf and then verify it again at a time after its expiry, which fails with x509.CertificateInvalidError.

  • CreateCertificate, CreateCertificateRequest and CreateRevocationList build artefacts; they accept a crypto.Signer or, since Go 1.25, a crypto.MessageSigner. A missing SubjectKeyId is filled with a truncated SHA-256 (Go 1.25).

  • x509.OID and ParseOID give typed object identifiers; ExtKeyUsage and KeyUsage have String methods (1.26).

  • SHA-1 based signatures are no longer accepted by Verify (Go 1.24); ParseCertificate rejects negative serial numbers and other malformed encodings by default.

  • Policy validation (Certificate.Policies, VerifyOptions.CertificatePolicies, policy mappings and constraints) arrived in Go 1.24 for private PKI deployments.

  • Go 1.27: Certificate.RawSignatureAlgorithm exposes the DER-encoded signature algorithm, and ML-DSA keys and signatures are supported.

Parsing is a parsing-of-untrusted-input problem: it is bounded and fuzzed by the Go team, but a service that accepts client certificates should still limit sizes at the TLS layer and handle errors, never panic.

FIPS 140-3 and the Go Cryptographic Module

FIPS 140-3 is a US government compliance regime that requires approved algorithms and a validated cryptographic module. The Go Cryptographic Module (the packages under crypto/internal/fips140/...) implements those algorithms; the public packages (crypto/ecdsa, crypto/rand, …​) use it transparently, so applications need no code changes for the approved algorithms. The Go team states that using a validated module is not on its own enough to satisfy regulations and gives no guarantee about any one regulatory requirement; applications with no compliance requirement should simply not enable FIPS mode.

Three controls exist, at build time and at run time:

Control Meaning

GOFIPS140=off

Build-time default: use the crypto/internal/fips140/... packages of the standard library in use, FIPS mode off.

GOFIPS140=latest

Like off, but FIPS 140-3 mode is on by default.

GOFIPS140=v1.0.0, v1.26.0

Link a frozen module version (the tree is stored in GOROOT/lib/fips140); FIPS mode on by default. Go 1.27.2 ships v1.0.0 and v1.26.0.

GOFIPS140=inprocess, certified

The latest version on the CMVP Modules In Process list, or the latest that holds a validation certificate.

GODEBUG=fips140=on

Turn FIPS mode on at run time, even for a binary built without GOFIPS140. Cannot change after start-up (changing it later is a no-op since Go 1.25).

GODEBUG=fips140=only

Like on, but algorithms that are not approved return errors or panic. Best effort, for testing, assessment and debugging; not for production, not required by the security policy, may give false positives and negatives.

GODEBUG=fips140ems=0

Go 1.27: disables the Extended Master Secret requirement in FIPS mode for TLS 1.2.

go build, go install and go test accept GOFIPS140; go env GOFIPS140 shows the current setting (off here). The crypto/fips140 package reports the state: Enabled(), Enforced() (true in only mode), Version() (the frozen module version, or latest when built against the live tree) and WithoutEnforcement(f) (Go 1.26), which runs f with the strict only checks disabled so legacy code paths can coexist with enforcement. The package and GOFIPS140 arrived in Go 1.24; Enforced, Version and WithoutEnforcement came with Go 1.26.

package main

import (
	"crypto/fips140"
	"crypto/md5"
	"crypto/sha256"
	"fmt"
)

func tryMD5() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("md5 panicked:", r)
		}
	}()
	fmt.Printf("md5 %x\n", md5.Sum([]byte("abc")))
}

func main() {
	fmt.Println("enabled:", fips140.Enabled(), "enforced:", fips140.Enforced(), "version:", fips140.Version())
	fmt.Printf("sha256 %x\n", sha256.Sum256([]byte("abc"))) // approved

	tryMD5() // not approved

	// Go 1.26: opt a block of legacy code out of the strict checks.
	fips140.WithoutEnforcement(func() {
		fmt.Println("inside: enforced:", fips140.Enforced())
		tryMD5()
	})
}

Run normally, with GODEBUG=fips140=only, and then built against a frozen module:

$ go run ./e11
enabled: false enforced: false version: latest
sha256 ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
md5 900150983cd24fb0d6963f7d28e17f72
inside: enforced: false
md5 900150983cd24fb0d6963f7d28e17f72
$ GODEBUG=fips140=only go run ./e11
enabled: true enforced: true version: latest
sha256 ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
md5 panicked: crypto/md5: use of MD5 is not allowed in FIPS 140-only mode
inside: enforced: false
md5 900150983cd24fb0d6963f7d28e17f72
$ GOFIPS140=v1.26.0 go build -o e11bin ./e11
$ ./e11bin | head -1
enabled: true enforced: false version: v1.26.0
$ GODEBUG=fips140=off ./e11bin | head -1
enabled: false enforced: false version: v1.26.0
$ go version -m e11bin | grep -i fips
	build	-tags=fips140v1.26
	build	DefaultGODEBUG=fips140=on
	build	GOFIPS140=v1.26.0

The go version -m lines show that the choice is recorded in the binary as the build tag fips140v1.26, the build setting GOFIPS140 and a DefaultGODEBUG of fips140=on, so you can audit which module a deployed binary uses without running it.

What changes in FIPS mode (from the official documentation):

  • The module performs an integrity self-check at init time and known-answer self-tests per algorithm, and pairwise consistency tests on generated keys, which can make key generation up to twice as slow.

  • crypto/rand.Reader is an SP 800-90A DRBG, with the platform CSPRNG still mixed in at every Read.

  • crypto/tls silently ignores and does not negotiate protocol versions, cipher suites, signature algorithms and key exchanges that are not approved (this replaces the old crypto/tls/fipsonly of Go+BoringCrypto). Extended Master Secret is required in TLS 1.2 (Go 1.25), and Ed25519 and X25519MLKEM768 are allowed.

  • rsa.SignPSS with PSSSaltLengthAuto caps the salt at the hash length.

  • In only mode, pbkdf2 and hkdf also enforce minimum sizes: the example below printed the real error for a short salt.

  • FIPS mode is not supported on OpenBSD, Wasm, AIX and 32-bit Windows.

$ go run ./e16
crypto/rsa: 512-bit keys are insecure (see https://go.dev/pkg/crypto/rsa#hdr-Minimum_key_size)
<nil>
$ GODEBUG=fips140=only go run ./e16
crypto/rsa: 512-bit keys are insecure (see https://go.dev/pkg/crypto/rsa#hdr-Minimum_key_size)
crypto/pbkdf2: use of salts shorter than 128 bits is not allowed in FIPS 140-only mode

Module versions: v1.0.0 was submitted to CMVP (certificate #5247 as stated on the Go documentation page at the time of checking) and is available from Go 1.24; v1.26.0 was frozen from Go 1.26 and added ML-DSA, SetGlobalRandom support, new AES-GCM compliance APIs used by crypto/hpke, and a CPU jitter entropy source. Some standard-library features are unavailable when you pin an older frozen module: with GOFIPS140=v1.0.0, building and running the ML-DSA example above stops at its first call with the error mldsa: unavailable in FIPS 140-3 Go Cryptographic Module v1.0.0. The earlier, unsupported Go+BoringCrypto mechanism is incompatible with this one and is slated for removal.

Vulnerability management

The Go vulnerability management system has three parts, described on go.dev/doc/security/vuln:

  1. A data pipeline collects reports from the National Vulnerability Database, the GitHub Advisory Database and package maintainers.

  2. The Go vulnerability database (https://vuln.go.dev) stores curated reports in the OSV format and serves them over a plain HTTP JSON API. Entries carry the affected packages and symbols, version ranges and fixes, and deliberately no severity labels, because the impact of a bug depends on how the caller uses the function. You can browse it at https://pkg.go.dev/vuln, and pkg.go.dev shows a banner on vulnerable package versions.

  3. Tools that read it: govulncheck, the Go extension for VS Code (through gopls), and pkg.go.dev.

govulncheck

govulncheck (module golang.org/x/vuln, latest v1.9.0 on 2026-10-18) reports known vulnerabilities that affect your code. It combines the module graph with static analysis of which vulnerable functions are actually reachable, so a vulnerable dependency you never call shows up as informational rather than as a finding. Neither govulncheck nor access to the vulnerability database is available where this page was checked, so the commands below are not run; they are taken from the tool’s documentation.

# install a pinned version (not run)
go install golang.org/x/vuln/cmd/govulncheck@v1.9.0

# source mode: from the module root, same package patterns as the go command
govulncheck ./...

# more detail
govulncheck -show verbose ./...      # progress messages and extra findings
govulncheck -show traces ./...       # full call stacks
govulncheck -tags integration -test ./...   # include build tags and test files

# binary mode: scan an already built executable
govulncheck -mode binary $HOME/go/bin/shelfd

# machine-readable output for CI and dashboards
govulncheck -format json ./...       # streaming JSON
govulncheck -format sarif ./... > govulncheck.sarif
govulncheck -format openvex ./...

Facts from the tool documentation and the official tutorial:

  • Source mode (the default) analyses the packages you name, with the Go version of the go command on PATH as the standard-library baseline; the result lists each finding with a short call stack such as main.go:12:29: mymodule.main calls golang.org/x/text/language.Parse. Vulnerabilities in imported packages that your code does not reach appear under an "Informational" heading with a note that you may not need to act.

  • Binary mode reads the symbol table of a compiled program, so it can scan something you did not build, but it prints no call stacks, may report code that is present but unreachable, and for the main module needs a precise module version. -mode extract writes a small blob that can be scanned later with -mode binary. A binary records its Go version and module dependencies; go version -m shows the same data, here for the TLS example binary built with -trimpath:

    $ go build -trimpath -o e10bin ./e10
    $ go version -m e10bin
    e10bin: go1.27.2
    	path	example.com/shelf/e10
    	mod	example.com/shelf	(devel)
    	build	-buildmode=exe
    	build	-compiler=gc
    	build	-trimpath=true
    	build	CGO_ENABLED=1
    	build	GOARCH=arm64
    	build	GOOS=darwin
    	build	GOARM64=v8.0

    This binary has no third-party dependencies, so only its toolchain version (go1.27.2) matters for standard-library advisories; with dependencies, a dep line per module would appear.

  • Exit status: 0 when there are no findings, non-zero when there are, except that -format json, sarif and openvex always exit 0 so the consumer decides.

  • Privacy: requests to the database contain only module paths with known vulnerabilities, not your code. -db points at a different database that implements the same specification (a mirror or an internal one).

  • Limitations stated by the tool: calls through function pointers and interfaces are analysed conservatively (false positives), calls through reflect are invisible and unsafe can cause false negatives, there is no way to suppress a finding yet, binaries cannot show call graphs, and binaries built with Go before 1.18 report only standard-library issues.

  • Fixing: upgrade the module to the "Fixed in" version (go get golang.org/x/text@v0.3.8 style, then go mod tidy) or stop calling the vulnerable symbol. Keep the Go toolchain itself current, because standard-library advisories are fixed in point releases (Toolchain Versions and Compatibility).

Since Go 1.24 a module can pin tools in go.mod with a tool directive, which gives a CI job a reproducible version of govulncheck without a separate install step (not run: needs the network):

module example.com/shelf

go 1.27

tool golang.org/x/vuln/cmd/govulncheck

require golang.org/x/vuln v1.9.0
# adds the tool line and the requirement (not run)
go get -tool golang.org/x/vuln/cmd/govulncheck@v1.9.0
# runs the pinned version
go tool govulncheck ./...

The require line needs the module’s own dependencies too, which go get adds along with their entries in go.sum; the snippet above shows only the line that matters here.

In the editor and in CI

  • VS Code and gopls (v0.11.0 or newer) offer two modes: an imports-based analysis that is fast and shows diagnostics in go.mod for every vulnerable package you import (it can over-report), and the embedded govulncheck analysis that you trigger on demand and confirms real call paths. Neither scans private modules or sends information about them. Editor features do not report standard-library or toolchain vulnerabilities yet.

  • CI: run govulncheck ./... on every pull request and on a schedule (new advisories appear for unchanged code), fail on a non-zero exit, or publish SARIF to your code-scanning dashboard. The Go team provides a GitHub Action for it on the GitHub Marketplace.

  • Dependencies: review upgrades rather than taking latest blindly; go.sum and the checksum database (https://sum.golang.org) detect tampered modules, as covered in Modules and Dependency Management.

The security policy and release practice

  • Report a vulnerability in the Go project (standard library, toolchain, golang.org/x) by email to security@golang.org with a subject of the form "Vulnerability: package name: one-line summary", one report per email and no attachments unless needed. The team acknowledges within 7 days and aims to fix or publish within 90 days of the acknowledgement. Please curate machine-generated reports: the policy asks reporters not to forward uncurated tool output.

  • Issues are filed in the PUBLIC, PRIVATE or URGENT track depending on their impact; all get a CVE. Go does not assign severity labels or CVSS scores.

  • Security fixes are issued for the two most recent major releases (Go 1.27 and Go 1.26 on 2026-10-18), pre-announced on the golang-announce mailing list, and shipped in point releases; update to the latest minor release of your major version.

  • The Go security team is a CVE Numbering Authority. To add a public vulnerability in someone else’s package to the database, use the form linked from https://go.dev/doc/security/vuln/; vulnerabilities in the Go project itself follow the policy above.

Secure coding practices

Official best practices

The Go "Security Best Practices for Go Developers" page lists five habits that pay off before any specific bug:

  1. Scan source and binaries with govulncheck regularly (above).

  2. Keep the Go version and the dependencies up to date, and review dependency updates instead of accepting them blindly.

  3. Fuzz code that parses untrusted input (Benchmarks, Fuzzing and synctest).

  4. Run the race detector, go test -race, in CI (Memory Model and Data Races).

  5. Run go vet ./... (Formatting, vet, fix and Static Analysis).

Untrusted file names: os.Root

Path traversal happens when an attacker-influenced name makes a program open a different file than intended: ../ components, symbolic links, Windows device names, or a race in which a directory is replaced between a check and the open. Sanitising the string with filepath.IsLocal (Go 1.20) or filepath.Localize (Go 1.23) is the first step but does not stop symbolic links or races. os.OpenRoot (Go 1.24) returns an *os.Root: a handle to a directory whose methods can only reach files beneath it. Go 1.25 grew the API to ReadFile, WriteFile, Mkdir, MkdirAll, Remove, RemoveAll, Rename, Symlink, Link, Chmod and friends; os.OpenInRoot and Root.OpenRoot (both Go 1.24) give one-shot opens and sub-roots.

package main

import (
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

func main() {
	base, err := os.MkdirTemp("", "shelf-covers-*")
	if err != nil {
		panic(err)
	}
	defer os.RemoveAll(base)

	uploads := filepath.Join(base, "uploads")
	if err := os.Mkdir(uploads, 0o755); err != nil {
		panic(err)
	}
	// A secret outside the upload directory, and a symlink pointing at it.
	if err := os.WriteFile(filepath.Join(base, "secret.txt"), []byte("not for clients"), 0o600); err != nil {
		panic(err)
	}
	if err := os.Symlink("../secret.txt", filepath.Join(uploads, "link.txt")); err != nil {
		panic(err)
	}

	root, err := os.OpenRoot(uploads)
	if err != nil {
		panic(err)
	}
	defer root.Close()

	if err := root.WriteFile("cover-1.txt", []byte("front cover"), 0o644); err != nil {
		panic(err)
	}
	data, err := root.ReadFile("cover-1.txt")
	fmt.Printf("%s %v\n", data, err)

	// Escapes are rejected, whether by ".." or by a symlink.
	for _, name := range []string{"../secret.txt", "link.txt", "/etc/passwd", "a/../../secret.txt"} {
		_, err := root.ReadFile(name)
		fmt.Printf("%-20s %s\n", name, short(err, base))
	}

	// Compare with the unsafe pattern: Join + Open follows ".." out of the tree.
	leaked, err := os.ReadFile(filepath.Join(uploads, "../secret.txt"))
	fmt.Printf("unsafe join: %s %v\n", leaked, err)

	// Validate first when you only have a name: filepath.IsLocal.
	for _, name := range []string{"cover-1.txt", "../secret.txt", "/abs", ""} {
		fmt.Printf("IsLocal(%q) = %v\n", name, filepath.IsLocal(name))
	}

	// A Root can be used where an fs.FS is expected.
	fsys := root.FS()
	_, err = fsys.Open("../secret.txt")
	fmt.Println("fs.FS:", short(err, base))
}

func short(err error, base string) string {
	if err == nil {
		return "<nil>"
	}
	return strings.ReplaceAll(err.Error(), base, "$BASE")
}
front cover <nil>
../secret.txt        openat ../secret.txt: path escapes from parent
link.txt             openat link.txt: path escapes from parent
/etc/passwd          openat /etc/passwd: path escapes from parent
a/../../secret.txt   openat a/../../secret.txt: no such file or directory
unsafe join: not for clients <nil>
IsLocal("cover-1.txt") = true
IsLocal("../secret.txt") = false
IsLocal("/abs") = false
IsLocal("") = false
fs.FS: open ../secret.txt: invalid argument

Root follows symbolic links, but only if the final target stays inside the root and the link is not absolute, which is why link.txt (pointing at ../secret.txt) is rejected. It does not stop traversal of mount points, Linux bind mounts, /proc special files or Unix device files, and on Unix Chmod, Chown and Chtimes have a small race if the target is swapped for a symlink. The unsafe filepath.Join pattern in the example still reads the secret: joining and opening a user-supplied name is the vulnerability. Use Root for upload directories, archive extraction and anything that serves user-chosen file names (it also exposes an fs.FS through Root.FS(), usable with http.FileServerFS; see I/O, Files and the Filesystem).

Output encoding: html/template

text/template writes values verbatim; html/template escapes them according to where they appear (element text, attribute, URL, URL query, JavaScript value), so the same value is safely rendered differently in each context. The second rendering below shows four different escapings of the same hostile title, a URL neutralised to #ZgotmplZ, and the template.HTML type turning the protection off:

package main

import (
	"fmt"
	htmltemplate "html/template"
	"os"
	texttemplate "text/template"
)

type Book struct {
	Title string
	URL   string
}

func main() {
	b := Book{
		Title: `<script>alert("x")</script> & Co`,
		URL:   "javascript:alert(1)",
	}

	const page = `<p title="{{.Title}}">{{.Title}}</p>
<a href="{{.URL}}">link</a>
<a href="/search?q={{.Title}}">search</a>
<script>var title = {{.Title}};</script>
`
	fmt.Println("--- text/template (no escaping)")
	texttemplate.Must(texttemplate.New("t").Parse(page)).Execute(os.Stdout, b)

	fmt.Println("--- html/template (context-aware)")
	htmltemplate.Must(htmltemplate.New("h").Parse(page)).Execute(os.Stdout, b)

	// Wrapping untrusted text in htmltemplate.HTML turns the escaping OFF.
	fmt.Println("--- htmltemplate.HTML bypasses escaping")
	htmltemplate.Must(htmltemplate.New("x").Parse("<div>{{.}}</div>\n")).Execute(os.Stdout, htmltemplate.HTML(b.Title))
}
--- text/template (no escaping)
<p title="<script>alert("x")</script> & Co"><script>alert("x")</script> & Co</p>
<a href="javascript:alert(1)">link</a>
<a href="/search?q=<script>alert("x")</script> & Co">search</a>
<script>var title = <script>alert("x")</script> & Co;</script>
--- html/template (context-aware)
<p title="&lt;script&gt;alert(&#34;x&#34;)&lt;/script&gt; &amp; Co">&lt;script&gt;alert(&#34;x&#34;)&lt;/script&gt; &amp; Co</p>
<a href="#ZgotmplZ">link</a>
<a href="/search?q=%3cscript%3ealert%28%22x%22%29%3c%2fscript%3e%20%26%20Co">search</a>
<script>var title = "\u003cscript\u003ealert(\"x\")\u003c/script\u003e \u0026 Co";</script>
--- htmltemplate.HTML bypasses escaping
<div><script>alert("x")</script> & Co</div>

Always use html/template for HTML, never text/template or string concatenation. Types such as template.HTML, template.JS, template.URL and template.HTMLAttr mark content as already safe and bypass escaping: never convert user input to them. #ZgotmplZ in a rendered page means the template engine refused an unsafe value in a URL or CSS position.

SQL injection

Concatenating user input into SQL lets the input change the statement. With database/sql, pass values as arguments after the statement so the driver sends them separately; the placeholder syntax (?, $1, @name) depends on the driver. This fragment compiles and vets but is not run: there is no database in this environment. The full treatment, including sql.Null[T], prepared statements and transactions, is in database/sql and the SQL fundamentals on this site start at Data Control Language (DCL) (privileges, so the application account can do no more than it needs).

package store

import (
	"context"
	"database/sql"
	"fmt"
)

// Vulnerable: the title is pasted into the SQL text.
func FindUnsafe(ctx context.Context, db *sql.DB, title string) (*sql.Rows, error) {
	q := fmt.Sprintf("SELECT isbn, title FROM books WHERE title = '%s'", title)
	return db.QueryContext(ctx, q)
}

// Safe: the driver sends the value separately from the statement.
func Find(ctx context.Context, db *sql.DB, title string) (*sql.Rows, error) {
	return db.QueryContext(ctx, "SELECT isbn, title FROM books WHERE title = $1", title)
}

Placeholders protect values, not identifiers: a table or column name taken from input must be checked against an allow-list.

Requests, servers and browsers

  • Cap request bodies with http.MaxBytesReader, set ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout and MaxHeaderBytes on http.Server, and bound concurrency; a default http.Server{} has no timeouts and is open to slow-client exhaustion.

  • http.NewCrossOriginProtection() (Go 1.25) rejects cross-origin, state-changing browser requests by inspecting Sec-Fetch-Site and Origin, replacing token-based CSRF in many apps. Combine it with security headers such as X-Content-Type-Options: nosniff and a Content-Security-Policy; see CORS.

  • Validate and normalise untrusted input before using it, and keep encoding/json strictness (DisallowUnknownFields) on request parsers.

package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"time"
)

func newServer(addr string) *http.Server {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /loans", func(w http.ResponseWriter, r *http.Request) {
		r.Body = http.MaxBytesReader(w, r.Body, 64) // cap the body at 64 bytes
		if _, err := io.Copy(io.Discard, r.Body); err != nil {
			http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
			return
		}
		w.WriteHeader(http.StatusCreated)
	})

	// Go 1.25: reject cross-origin browser requests that change state.
	cop := http.NewCrossOriginProtection()

	return &http.Server{
		Addr:              addr,
		Handler:           securityHeaders(cop.Handler(mux)),
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      10 * time.Second,
		IdleTimeout:       60 * time.Second,
		MaxHeaderBytes:    16 << 10,
	}
}

func securityHeaders(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		h := w.Header()
		h.Set("X-Content-Type-Options", "nosniff")
		h.Set("Content-Security-Policy", "default-src 'none'")
		h.Set("Referrer-Policy", "no-referrer")
		next.ServeHTTP(w, r)
	})
}

func main() {
	s := newServer(":0")
	ts := httptest.NewServer(s.Handler)
	defer ts.Close()

	post := func(body string, hdr map[string]string) {
		req, _ := http.NewRequest("POST", ts.URL+"/loans", strings.NewReader(body))
		for k, v := range hdr {
			req.Header.Set(k, v)
		}
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			panic(err)
		}
		b, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		fmt.Printf("%d %q nosniff=%s\n", resp.StatusCode, strings.TrimSpace(string(b)), resp.Header.Get("X-Content-Type-Options"))
	}
	post("small", nil)
	post(strings.Repeat("x", 200), nil)
	post("small", map[string]string{"Sec-Fetch-Site": "cross-site"})
	post("small", map[string]string{"Sec-Fetch-Site": "same-origin"})
}
201 "" nosniff=nosniff
413 "body too large" nosniff=nosniff
403 "cross-origin request detected from Sec-Fetch-Site header" nosniff=nosniff
201 "" nosniff=nosniff

The four requests show a small body accepted (201), an oversized body rejected (413), a request from another site refused (403) before the handler runs, and a same-origin request accepted. Non-browser clients that send neither header are allowed through, so this protects browsers specifically and is not a replacement for authentication. See net/http Clients and Servers for the server settings in depth.

Secrets in logs and errors

Credentials leak through logs, error messages, panics and %+v dumps far more often than through cryptanalysis. Give secret types a LogValue method (slog.LogValuer), a String and a GoString, and keep secrets out of error text.

package main

import (
	"errors"
	"fmt"
	"log/slog"
	"os"
)

// Secret hides its value from logs, fmt and JSON output.
type Secret string

func (Secret) String() string               { return "[REDACTED]" }
func (Secret) GoString() string             { return "[REDACTED]" }
func (Secret) MarshalText() ([]byte, error) { return []byte("[REDACTED]"), nil }
func (Secret) LogValue() slog.Value         { return slog.StringValue("[REDACTED]") }

type Config struct {
	DSN   string // contains a password: never log it
	Token Secret
}

func main() {
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey && len(groups) == 0 {
				return slog.Attr{} // deterministic output for the example
			}
			return a
		},
	}))

	cfg := Config{DSN: "postgres://shelf:example-password@db.internal/shelf", Token: "example-token-not-real"}

	logger.Info("starting", "token", cfg.Token)
	logger.Info("config", slog.Any("cfg", cfg))
	fmt.Printf("%v %+v %#v %s\n", cfg.Token, cfg, cfg, cfg.Token)

	// Do not put credentials into errors either: name the host, not the DSN.
	cause := errors.New("connection refused")
	bad := fmt.Errorf("connect to %s: %w", cfg.DSN, cause)
	good := fmt.Errorf("connect to db.internal: %w", cause)
	logger.Error("db", "bad", bad)
	logger.Error("db", "good", good)
}
level=INFO msg=starting token=[REDACTED]
level=INFO msg=config cfg="{DSN:postgres://shelf:example-password@db.internal/shelf Token:[REDACTED]}"
[REDACTED] {DSN:postgres://shelf:example-password@db.internal/shelf Token:[REDACTED]} main.Config{DSN:"postgres://shelf:example-password@db.internal/shelf", Token:[REDACTED]} [REDACTED]
level=ERROR msg=db bad="connect to postgres://shelf:example-password@db.internal/shelf: connection refused"
level=ERROR msg=db good="connect to db.internal: connection refused"

Three lessons are visible in the output. A Secret field prints as [REDACTED] through slog, %v, %+v, %#v and %s. The plain string DSN field in the same struct still leaks its password, because redaction is a property of the type: make every secret-carrying field a Secret. And an error built with fmt.Errorf("connect to %s: %w", dsn, cause) leaks it again, so name the host, not the connection string. Also: do not put secrets in command-line arguments (they appear in ps), prefer environment variables or files with restrictive permissions read at start-up, and remember that Go gives you no reliable way to wipe a string or key from memory (the garbage collector may copy it), so limit how long secrets live rather than rely on zeroing.

Other hazards

  • Running commands: os/exec refuses to run a program found relative to the current directory (exec.ErrDot); keep it that way and pass arguments as a slice, never through a shell string (see Command-Line Programs).

  • Unsafe memory: unsafe and cgo bypass the compiler’s safety guarantees; confine them and fuzz them (unsafe and Low-Level Programming, cgo and C Interoperability).

  • Integer overflow and slice bounds: Go integers wrap silently; check sizes you derive from input before allocating or indexing (Operators and Expressions).

  • Data races are security bugs in servers; run with -race in CI.

  • Supply chain: pin versions with go.mod, review replace directives, keep GOFLAGS=-mod=readonly in CI so builds do not edit module files, and do not disable the checksum database (GONOSUMDB, GOFLAGS=-insecure) for public modules.

  • Tokens and JWTs: verify signatures with a vetted library, check alg, issuer, audience and expiry; see JWT and JOSE and OAuth security best practices. Never roll your own session scheme out of crypto/hmac when a standard one fits.

Compile errors and run-time failures

Symptom Cause Fix

in call to hkdf.Key, type hash.Hash of sha256.New() does not match func() Hash (cannot infer Hash)

Passing a hash value where the KDF wants a constructor.

Pass sha256.New, not sha256.New().

cipher: message authentication failed

Wrong key, wrong nonce prefix, modified ciphertext or different associated data.

Treat as an attack or a bug; never fall back to unauthenticated decryption.

crypto/rsa: 512-bit keys are insecure

RSA key under 1024 bits (Go 1.24 and later).

Use 2048 bits or more, or Ed25519 or ECDSA.

x509: certificate signed by unknown authority

The issuing CA is not in RootCAs (or the system store).

Add the CA to a CertPool; do not use InsecureSkipVerify.

x509: certificate is valid for A, not B

Name mismatch (HostnameError).

Fix the certificate’s SAN list or the name you dial; the common name is not enough.

tls: failed to verify certificate: x509: certificate has expired or is not yet valid

Certificate dates or a wrong clock.

Renew; check NotBefore and NotAfter and time sync.

mldsa: invalid signature

Message, key or Options.Context differs from signing.

Verify with the same context string.

mlkem: invalid ciphertext length

A ciphertext of the wrong size.

Check what was transmitted; ML-KEM-768 ciphertexts are 1088 bytes.

crypto/md5: use of MD5 is not allowed in FIPS 140-only mode

An unapproved algorithm in GODEBUG=fips140=only.

Use an approved algorithm, or fips140.WithoutEnforcement for a justified exception.

mldsa: unavailable in FIPS 140-3 Go Cryptographic Module v1.0.0

The code uses ML-DSA with a frozen v1.0.0 module.

Build with GOFIPS140=v1.26.0 or later.

path escapes from parent

A name given to os.Root leaves the root.

Reject the input; it is not a bug in the root.

#ZgotmplZ in HTML output

html/template refused an unsafe value for a URL or CSS context.

Validate the value, or construct a safe type (template.URL) only after checking it.

bcrypt: password length exceeds 72 bytes

Password longer than bcrypt’s input limit.

Use Argon2id, or pre-hash with a keyed hash (document the choice).

go vet has no cryptography-specific analyzers; the reports that matter here are the general ones (printf for log lines, copylocks, lostcancel). Third-party linters such as staticcheck (deprecated API use, for example CFB or OFB modes) and gosec (hard-coded credentials, weak hashes, InsecureSkipVerify) are common in CI; they are not run here.

Where the books are outdated

The two books this reference consulted for structure (Bodner, Learning Go 1st edition, and Vivien, Learning Go Programming) were written before most of this page’s subject existed, so check current documentation for anything below:

  • Post-quantum primitives. crypto/mlkem (1.24), crypto/hpke (1.26) and crypto/mldsa (1.27), and hybrid post-quantum TLS key exchange on by default since Go 1.24.

  • Randomness. rand.Text (1.24), rand.Read that cannot fail (1.24), the 1.26 rule that randomness arguments are ignored and cryptotest.SetGlobalRandom. Older material that seeds math/rand with the clock for tokens is dangerous; math/rand/v2 is also not cryptographic.

  • Key derivation. crypto/hkdf, crypto/pbkdf2 and crypto/sha3 are in the standard library since Go 1.24, no longer only in x/crypto.

  • TLS defaults. TLS 1.2 minimum, no RSA key exchange or 3DES in defaults, SHA-1 gone from verification and TLS 1.2 signatures, and the tlsrsakex, tls3des, tls10server, tlsunsafeekm and x509keypairleaf settings removed in Go 1.27.

  • FIPS. The native FIPS 140-3 module and GOFIPS140 replace the Go+BoringCrypto approach.

  • Vulnerability management. govulncheck, the Go vulnerability database and pkg.go.dev’s vulnerability banners did not exist.

  • Files. os.Root, filepath.IsLocal and filepath.Localize replace hand-written path sanitising.

  • Modules and tooling. go.mod tool directives (1.24) for pinning tools such as govulncheck; io/ioutil is deprecated, use os and io.

Version notes

  • Go 1.20: filepath.IsLocal; crypto/subtle.XORBytes.

  • Go 1.22: TLS 1.2 minimum by default; RSA key exchange removed from default suites.

  • Go 1.23: 3DES removed from default suites; filepath.Localize; x509.ParseOID; the tlskyber setting added (removed in 1.24).

  • Go 1.24: crypto/mlkem, crypto/hkdf, crypto/pbkdf2, crypto/sha3, crypto/fips140 and GOFIPS140, rand.Text, rand.Read cannot fail, cipher.NewGCMWithRandomNonce, subtle.WithDataIndependentTiming, os.Root and os.OpenRoot, hybrid X25519MLKEM768 in TLS by default, TLS server Encrypted Client Hello, RSA keys under 1024 bits rejected, SHA-1 removed from x509.Verify, OFB and CFB deprecated, tool directives in go.mod.

  • Go 1.25: sha3.SHA3.Clone, ConnectionState.CurveID, GetEncryptedClientHelloKeys, SHA-1 TLS 1.2 signatures disabled, server prefers the highest TLS version, crypto.MessageSigner, ecdsa.ParseRawPrivateKey and Bytes, http.CrossOriginProtection, a larger os.Root API (ReadFile, WriteFile, MkdirAll, …​), changing GODEBUG=fips140 after start-up is a no-op.

  • Go 1.26: crypto/hpke, crypto/mlkem/mlkemtest, crypto.Encapsulator and Decapsulator, ecdh.KeyExchanger, testing/cryptotest.SetGlobalRandom and random arguments ignored, fips140.Enforced, Version and WithoutEnforcement, Go Cryptographic Module v1.26.0 selectable with GOFIPS140, SecP256r1MLKEM768 and SecP384r1MLKEM1024 on by default, zero value of sha3.SHA3 usable.

  • Go 1.27: crypto/mldsa and crypto.MLDSAMu, ML-DSA in x509 and tls, tls.MLKEM1024, Config.Rand deprecated, ConnectionState.LocalCertificate, GODEBUG=fips140ems, SystemCertPool honours SSL_CERT_FILE and SSL_CERT_DIR on macOS and Windows, the tlsrsakex, tls3des, tls10server, tlsunsafeekm and x509keypairleaf settings removed. Examples were checked with go 1.27 in go.mod. Go 1.28 has not been released, so nothing here depends on it.