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
cryptopackage 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/tlsandcrypto/x509choose algorithms and parameters for you,cipher.NewGCMWithRandomNonceremoves nonce management,crypto/hpkebundles key agreement, key derivation and encryption,rand.Readcannot 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
GODEBUGsetting that restores the old behaviour for a while; see Toolchain Versions and Compatibility for howGODEBUGdefaults follow thegoline ingo.mod. -
A frozen, validated module is available. The Go Cryptographic Module (FIPS 140-3) implements the approved algorithms, and
GOFIPS140selects 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,nacland others are in thex/cryptomodule (latest v0.58.0 on 2026-10-18), which has weaker compatibility promises than the standard library and needsgo 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
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 |
|---|---|---|
|
An |
1.0 |
|
Fills |
1.0 (no-fail 1.24) |
|
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 |
|
A uniform |
1.0 |
|
A random prime of the given size; the |
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.Textcome 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 takerand.Intmodulo something yourself: read a larger buffer and reject out-of-range values, or build the token fromrand.Textpieces. -
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.GenerateKeyandrand.Primenow always use a secure internal source, whateverio.Readeryou pass. Code that passed a deterministic reader to get repeatable keys in tests must change. The temporary escape hatch isGODEBUG=cryptocustomrand=1; the supported route istesting/cryptotest.SetGlobalRandom. -
ed25519.GenerateKey(nil)also uses the secure source (Go 1.26), so passingnilis fine. -
In FIPS 140-3 mode
rand.Readeris a NIST SP 800-90A DRBG, with the platform CSPRNG mixed in at everyRead.
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() }
Source: crypto/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
Source: testing/cryptotest
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 |
|---|---|---|
|
|
SHA-2, the default choice. 32-byte digest. |
|
|
SHA-2 with 64-bit words; often faster than SHA-256 on 64-bit CPUs for large inputs. |
|
|
SHA-3 and SHAKE extendable-output functions (FIPS 202). New in Go 1.24, moved from |
|
|
Broken for security. Fine for non-adversarial checksums and legacy protocols only. Rejected in |
|
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
Source: crypto/sha256, crypto/sha3 and hash
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
Sum256functions return arrays ([32]byte) and do not allocate; convert withsum[:]to get a slice. Printing an array with%xworks without slicing. -
hash.Hashimplementations are not safe for concurrent use, and since Go 1.24 the standard ones also implementencoding.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
Source: crypto/hmac and crypto/subtle
crypto/subtle is the home of the constant-time helpers:
| Function | Purpose |
|---|---|
|
Returns 1 if the slices have equal length and contents, 0 otherwise; the time depends only on the length. |
|
Compare integers without branching. |
|
Choose or copy based on a 0 or 1 selector without a branch. |
|
XOR two slices (Go 1.20); the output must overlap an input exactly or not at all, otherwise it panics (Go 1.24). |
|
Runs |
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.Extractandhkdf.Expandare the two steps. Theinfostring 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=onlymodepbkdf2.Keyrejects salts shorter than 128 bits, derived keys shorter than 112 bits and hashes other than SHA-2 or SHA-3;hkdfrejects 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 |
|---|---|---|
|
New systems: |
|
|
Legacy systems. |
Cost 4 to 31, default 10 (use 10 or more). Passwords longer than 72 bytes are rejected with |
|
When Argon2 is not available. |
The package documents |
// 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
Source: cipher.NewGCMWithRandomNonce and crypto/aes
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,NewCFBEncrypterandNewCFBDecryptergive 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 |
|---|---|---|
|
Default signature scheme: 32-byte keys, 64-byte signatures, deterministic, fast. |
|
|
When a protocol requires NIST curves (P-256, P-384, P-521). |
|
|
Legacy interoperability. Use PSS for signatures and OAEP for encryption. |
Keys under 1024 bits are rejected since Go 1.24 ( |
|
Key agreement (X25519, P-256, P-384, P-521). |
|
|
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>
Source: crypto/rsa: Minimum key size
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 |
|---|---|---|
|
Go 1.24 |
ML-KEM-768 and ML-KEM-1024 (FIPS 203). Go 1.26 added |
|
Go 1.24 |
Promoted from |
|
Go 1.26 |
Hybrid Public Key Encryption (RFC 9180), including post-quantum hybrid KEMs. Also in 1.26: |
|
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
Source: crypto/mlkem, NIST FIPS 203 and crypto/mlkem/mlkemtest
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
Source: crypto/mldsa and NIST FIPS 204
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 ( |
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); |
1.22, 1.23 |
Key exchange |
|
1.24, 1.26, 1.27 |
Ordering of |
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 ( |
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 |
1.24, 1.25 |
Randomness |
|
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
Source: crypto/tls, crypto/x509 and net/http/httptest
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 isX25519MLKEM768, 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 thetlsmlkem=0escape hatch documented inGODEBUG. -
Restricting
CurvePreferencestotls.X25519changes 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.HostnameErrorandx509.CertificateInvalidError(withReason, for examplex509.Expired). Test witherrors.As, never by matching text. -
The server’s handshake failures are logged on the
ErrorLogof thehttp.Server; the example discards them to keep the output stable.
Configuration advice
-
Never set
InsecureSkipVerify: trueoutside 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 inRootCAs(x509.NewCertPool,AppendCertsFromPEM) instead. -
Leave
MinVersionalone unless you control all clients; settls.VersionTLS13for 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
ClientAuthand aClientCAspool on the server andCertificateson 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
GetCertificateorGetConfigForClientwhen they rotate. -
tls.Configmust not be copied after use; clone it withClone().
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
Source: Server.ListenAndServeTLS and tls.Config
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(),AppendCertsFromPEMandSystemCertPool()build the set of trusted roots. On macOS and Windows, verification of the system roots is done by platform APIs; since Go 1.27,SystemCertPoolhonoursSSL_CERT_FILEandSSL_CERT_DIRon those systems too and then uses Go’s own verifier (GODEBUG=x509sslcertoverrideplatform=0disables that). -
Certificate.Verify(x509.VerifyOptions{Roots, Intermediates, DNSName, KeyUsages, CurrentTime})returns the verified chains;VerifyHostnamechecks 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 withx509.CertificateInvalidError. -
CreateCertificate,CreateCertificateRequestandCreateRevocationListbuild artefacts; they accept acrypto.Signeror, since Go 1.25, acrypto.MessageSigner. A missingSubjectKeyIdis filled with a truncated SHA-256 (Go 1.25). -
x509.OIDandParseOIDgive typed object identifiers;ExtKeyUsageandKeyUsagehaveStringmethods (1.26). -
SHA-1 based signatures are no longer accepted by
Verify(Go 1.24);ParseCertificaterejects 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.RawSignatureAlgorithmexposes 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 |
|---|---|
|
Build-time default: use the |
|
Like |
|
Link a frozen module version (the tree is stored in |
|
The latest version on the CMVP Modules In Process list, or the latest that holds a validation certificate. |
|
Turn FIPS mode on at run time, even for a binary built without |
|
Like |
|
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.Readeris an SP 800-90A DRBG, with the platform CSPRNG still mixed in at everyRead. -
crypto/tlssilently ignores and does not negotiate protocol versions, cipher suites, signature algorithms and key exchanges that are not approved (this replaces the oldcrypto/tls/fipsonlyof Go+BoringCrypto). Extended Master Secret is required in TLS 1.2 (Go 1.25), and Ed25519 andX25519MLKEM768are allowed. -
rsa.SignPSSwithPSSSaltLengthAutocaps the salt at the hash length. -
In
onlymode,pbkdf2andhkdfalso 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:
-
A data pipeline collects reports from the National Vulnerability Database, the GitHub Advisory Database and package maintainers.
-
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.
-
Tools that read it:
govulncheck, the Go extension for VS Code (throughgopls), 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
gocommand onPATHas the standard-library baseline; the result lists each finding with a short call stack such asmain.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 extractwrites a small blob that can be scanned later with-mode binary. A binary records its Go version and module dependencies;go version -mshows 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.0This binary has no third-party dependencies, so only its toolchain version (
go1.27.2) matters for standard-library advisories; with dependencies, adepline per module would appear. -
Exit status: 0 when there are no findings, non-zero when there are, except that
-format json,sarifandopenvexalways exit 0 so the consumer decides. -
Privacy: requests to the database contain only module paths with known vulnerabilities, not your code.
-dbpoints 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
reflectare invisible andunsafecan 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.8style, thengo 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 ingo.modfor 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
latestblindly;go.sumand 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 tosecurity@golang.orgwith 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-announcemailing 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:
-
Scan source and binaries with
govulncheckregularly (above). -
Keep the Go version and the dependencies up to date, and review dependency updates instead of accepting them blindly.
-
Fuzz code that parses untrusted input (Benchmarks, Fuzzing and synctest).
-
Run the race detector,
go test -race, in CI (Memory Model and Data Races). -
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
Source: The Go Blog: Traversal-resistant file APIs and os.Root
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="<script>alert("x")</script> & Co"><script>alert("x")</script> & 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>
Source: html/template and Text and HTML Templates
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)
}
Source: Avoiding SQL injection risk
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, setReadHeaderTimeout,ReadTimeout,WriteTimeout,IdleTimeoutandMaxHeaderBytesonhttp.Server, and bound concurrency; a defaulthttp.Server{}has no timeouts and is open to slow-client exhaustion. -
http.NewCrossOriginProtection()(Go 1.25) rejects cross-origin, state-changing browser requests by inspectingSec-Fetch-SiteandOrigin, replacing token-based CSRF in many apps. Combine it with security headers such asX-Content-Type-Options: nosniffand a Content-Security-Policy; see CORS. -
Validate and normalise untrusted input before using it, and keep
encoding/jsonstrictness (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
Source: http.CrossOriginProtection and http.MaxBytesReader
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"
Source: slog.LogValuer and Structured Logging with slog
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/execrefuses 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:
unsafeand 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
-racein CI. -
Supply chain: pin versions with
go.mod, reviewreplacedirectives, keepGOFLAGS=-mod=readonlyin 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 ofcrypto/hmacwhen a standard one fits.
Compile errors and run-time failures
| Symptom | Cause | Fix |
|---|---|---|
|
Passing a hash value where the KDF wants a constructor. |
Pass |
|
Wrong key, wrong nonce prefix, modified ciphertext or different associated data. |
Treat as an attack or a bug; never fall back to unauthenticated decryption. |
|
RSA key under 1024 bits (Go 1.24 and later). |
Use 2048 bits or more, or Ed25519 or ECDSA. |
|
The issuing CA is not in |
Add the CA to a |
|
Name mismatch ( |
Fix the certificate’s SAN list or the name you dial; the common name is not enough. |
|
Certificate dates or a wrong clock. |
Renew; check |
|
Message, key or |
Verify with the same context string. |
|
A ciphertext of the wrong size. |
Check what was transmitted; ML-KEM-768 ciphertexts are 1088 bytes. |
|
An unapproved algorithm in |
Use an approved algorithm, or |
|
The code uses ML-DSA with a frozen v1.0.0 module. |
Build with |
|
A name given to |
Reject the input; it is not a bug in the root. |
|
|
Validate the value, or construct a safe type ( |
|
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) andcrypto/mldsa(1.27), and hybrid post-quantum TLS key exchange on by default since Go 1.24. -
Randomness.
rand.Text(1.24),rand.Readthat cannot fail (1.24), the 1.26 rule that randomness arguments are ignored andcryptotest.SetGlobalRandom. Older material that seedsmath/randwith the clock for tokens is dangerous;math/rand/v2is also not cryptographic. -
Key derivation.
crypto/hkdf,crypto/pbkdf2andcrypto/sha3are in the standard library since Go 1.24, no longer only inx/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,tlsunsafeekmandx509keypairleafsettings removed in Go 1.27. -
FIPS. The native FIPS 140-3 module and
GOFIPS140replace 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.IsLocalandfilepath.Localizereplace hand-written path sanitising. -
Modules and tooling.
go.modtooldirectives (1.24) for pinning tools such asgovulncheck;io/ioutilis deprecated, useosandio.
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; thetlskybersetting added (removed in 1.24). -
Go 1.24:
crypto/mlkem,crypto/hkdf,crypto/pbkdf2,crypto/sha3,crypto/fips140andGOFIPS140,rand.Text,rand.Readcannot fail,cipher.NewGCMWithRandomNonce,subtle.WithDataIndependentTiming,os.Rootandos.OpenRoot, hybridX25519MLKEM768in TLS by default, TLS server Encrypted Client Hello, RSA keys under 1024 bits rejected, SHA-1 removed fromx509.Verify, OFB and CFB deprecated,tooldirectives ingo.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.ParseRawPrivateKeyandBytes,http.CrossOriginProtection, a largeros.RootAPI (ReadFile,WriteFile,MkdirAll, …), changingGODEBUG=fips140after start-up is a no-op. -
Go 1.26:
crypto/hpke,crypto/mlkem/mlkemtest,crypto.EncapsulatorandDecapsulator,ecdh.KeyExchanger,testing/cryptotest.SetGlobalRandomand random arguments ignored,fips140.Enforced,VersionandWithoutEnforcement, Go Cryptographic Module v1.26.0 selectable withGOFIPS140,SecP256r1MLKEM768andSecP384r1MLKEM1024on by default, zero value ofsha3.SHA3usable. -
Go 1.27:
crypto/mldsaandcrypto.MLDSAMu, ML-DSA inx509andtls,tls.MLKEM1024,Config.Randdeprecated,ConnectionState.LocalCertificate,GODEBUG=fips140ems,SystemCertPoolhonoursSSL_CERT_FILEandSSL_CERT_DIRon macOS and Windows, thetlsrsakex,tls3des,tls10server,tlsunsafeekmandx509keypairleafsettings removed. Examples were checked withgo 1.27ingo.mod. Go 1.28 has not been released, so nothing here depends on it.
References
-
crypto package documentation and its subpackages: rand, sha256, sha3, hmac, subtle, hkdf, pbkdf2, cipher, mlkem, hpke, mldsa, tls, x509 and fips140
-
golang.org/x/crypto (argon2, bcrypt, scrypt, chacha20poly1305)
-
FIPS 140-3 Compliance and The Go Blog: FIPS 140-3 compliance
-
Go Vulnerability Management, the Go vulnerability database, editor integration, the Go CNA, pkg.go.dev/vuln and vuln.go.dev
-
Tutorial: Find and fix vulnerable dependencies with govulncheck and govulncheck command documentation
-
NIST FIPS 203 (ML-KEM), NIST FIPS 204 (ML-DSA), RFC 9180 (HPKE), RFC 5869 (HKDF) and RFC 8446 (TLS 1.3)