Skip to content

Hashing and random values

A grab-bag of small standard-library packages that turn up constantly: hashing, signing, generating tokens, encoding bytes as text, and gzip. None is hard, and each has one way to get it wrong.

sum := sha256.Sum256([]byte("hello"))
fmt.Println(hex.EncodeToString(sum[:]))
// output: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824

Hashing

sha256.Sum256 is the one-shot form. It returns a fixed-size array, [32]byte, so passing it anywhere that wants a slice needs sum[:]:

sum := sha256.Sum256([]byte("hello"))
fmt.Println(len(sum))       // output: 32
fmt.Printf("%x\n", sum)     // output: 2cf24dba...938b9824

%x on the array works directly, which is the shortest way to print a digest.

For data you do not want to hold in memory, a hash is an io.Writer:

h := sha256.New()
io.Copy(h, r)
fmt.Println(hex.EncodeToString(h.Sum(nil)))

h.Sum(nil) appends the digest to the slice you pass — nil means "give me a fresh one". It does not reset the hash.

This is how you checksum a file without reading it whole, and how io.Copy from the readers and writers article pays off again.

SHA-256 is not for passwords. It is fast, which is exactly wrong for a password. Use a deliberately slow function such as bcrypt or Argon2 — both live outside the standard library.

HMAC: proving a message came from you

A plain hash proves integrity. An HMAC proves authenticity, because producing it requires the key:

key := []byte("secret")

m := hmac.New(sha256.New, key)
m.Write([]byte("message"))
sig := m.Sum(nil)

Verify by recomputing and comparing with hmac.Equal:

m2 := hmac.New(sha256.New, key)
m2.Write([]byte("message"))
fmt.Println(hmac.Equal(sig, m2.Sum(nil)))   // output: true

Use hmac.Equal, never == or bytes.Equal. A normal comparison returns as soon as two bytes differ, so how long it takes leaks how much of the signature was correct — enough, over many attempts, to recover a valid one. hmac.Equal always takes the same time.

This is the mechanism behind signed cookies and inbound webhook verification: the sender signs with a shared secret, you recompute and compare.

For non-HMAC secrets — an API token, a shared key — the general form is:

fmt.Println(subtle.ConstantTimeCompare([]byte("abc"), []byte("abc")))   // output: 1
fmt.Println(subtle.ConstantTimeCompare([]byte("abc"), []byte("abd")))   // output: 0

It returns an int, not a bool — 1 for equal. Note it returns 0 for inputs of different lengths without comparing at all, so length is not protected.

Random values: crypto/rand, not math/rand

Two packages, and picking the wrong one is a security bug rather than a style choice.

Package For
math/rand/v2 simulations, jitter, shuffling, test data
crypto/rand tokens, session IDs, keys, nonces, salts

math/rand is predictable from a handful of outputs. Anything an attacker must not guess comes from crypto/rand:

buf := make([]byte, 32)
n, err := rand.Read(buf)
fmt.Println(n, err)   // output: 32 <nil>

Thirty-two bytes is the usual size for a session token — 256 bits of entropy.

Encoding bytes as text

Random bytes are not printable, so they get encoded. The variant matters:

raw := []byte{0xfb, 0xff, 0x01}

fmt.Println(base64.StdEncoding.EncodeToString(raw))       // output: +/8B
fmt.Println(base64.URLEncoding.EncodeToString(raw))       // output: -_8B
fmt.Println(base64.RawURLEncoding.EncodeToString(raw))    // output: -_8B

StdEncoding produces +, / and =, all of which need escaping in a URL or a cookie. URLEncoding swaps in - and _; the Raw variants drop the = padding. For anything that travels in a URL, a header or a cookie, use RawURLEncoding:

tok := base64.RawURLEncoding.EncodeToString(buf)
fmt.Println(len(tok))   // output: 43

32 bytes becomes 43 characters with no padding and nothing to escape.

encoding/hex is the other option — twice as long, but unambiguous and the convention for digests.

hash/fnv for non-cryptographic hashing

When you need a number derived from a string — a shard index, a lock key, a cache bucket — a cryptographic hash is overkill. FNV is fast and deterministic across runs and machines:

f := fnv.New64a()
f.Write([]byte("job-name"))
fmt.Println(f.Sum64())   // output: 17796606368501603464

That determinism is the point: Go's built-in map hashing is randomised per process, so it cannot be used to agree on anything between processes. FNV can.

Never use it where an attacker chooses the input — it is trivial to produce collisions on purpose.

gzip

compress/gzip wraps a writer and a reader, so it composes with everything else:

var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
zw.Write(data)
zw.Close()          // required: writes the footer

Close is not optional — it flushes the final block and writes the gzip footer. A stream without it is truncated and will not decompress. Reading back:

zr, err := gzip.NewReader(&buf)
if err != nil {
    return err
}
out, err := io.ReadAll(zr)
if err != nil {
    return err
}

From Python: hashlib.sha256().hexdigest() becomes hex.EncodeToString(sum[:]), hmac.compare_digest becomes hmac.Equal, and secrets.token_urlsafe(32) becomes crypto/rand plus base64.RawURLEncoding. The same rule applies in both languages: random for simulations, secrets for security.

Quick reference

Task Call
hash a slice sha256.Sum256(b) → [32]byte, use sum[:]
hash a stream h := sha256.New(), io.Copy(h, r), h.Sum(nil)
digest as text hex.EncodeToString(...) or %x
sign hmac.New(sha256.New, key)
verify a signature hmac.Equal(a, b) — never ==
compare a secret subtle.ConstantTimeCompare(a, b) == 1
unguessable bytes crypto/rand.Read(buf)
token text base64.RawURLEncoding.EncodeToString(buf)
simulations, jitter math/rand/v2
deterministic bucket key fnv.New64a()
compress gzip.NewWriter(w) — must Close()
passwords not here; bcrypt or Argon2

Sources