Skip to content
Software Atlas

Hashing & Encryption

Encoding vs hashing vs encryption vs signing, SHA-256 and HMAC, AES-GCM and why nonces matter, Ed25519 signatures, password hashing with bcrypt and Argon2id, secure randomness, and how to choose libraries instead of inventing crypto, in Go and JavaScript.

⏱ ~55 min read14 sections12 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceFour tools that look alike and do completely different jobs: encoding changes the format, hashing makes a fingerprint, encryption locks data with a key, signing proves who wrote it. Most security bugs in this area start by using one where you needed another.

Two true-to-life disasters

Story 1. A photo-sharing startup stores passwords "encrypted" with MD5, a fast hash from 1992. A backup of the users table leaks. Within a day, attackers have recovered most of the passwords, because MD5 lets one graphics card test around 160 billion guesses per second (§08 shows the numbers). The passwords work on people's e-mail and bank accounts too, because people reuse them.

Story 2. A developer puts an API key in a config file as c2tfbGl2ZV81MWZh… with the comment "base64 so it's not in plain text". Anyone who copies that file runs one function and has the key. Base64 was never meant to hide anything.

Neither team was careless on purpose. They mixed up the tools. This page teaches you what each one is for, how to use it correctly in Go and JavaScript, and how attackers break it when it's misused.

Four everyday objects

1

A translation → encoding

The same letter, rewritten in another alphabet. Anyone with a dictionary can read it. No secret involved. Base64, Hexadecimal, UTF-8.

2

A fingerprint → hashing

Identifies a person, but you can't rebuild the person from it. Same finger, same print, every time. One-way. SHA-256, bcrypt.

3

A sealed envelope → encryption

Only someone with the key can open it, and a torn envelope shows it was tampered with. Two-way, with a key. AES-GCM.

What you'll be able to do after this page

JobToolSection
Check a download or a file wasn't corruptedSHA-256 hash§03
Prove a request or cookie came from your serverHMAC§04
Store a secret so only your app can read itAES-GCM encryption§05–§06
Prove a release, token or message came from youEd25519 signature§07
Store user passwordsArgon2id (bcrypt in older systems)§08–§09
Make tokens, IDs and keys nobody can guessthe secure random generator§10
Foundations§02

Encoding, hashing, encryption, signing

In one sentenceAsk two questions about any of these tools: can it be reversed? and who can do it? The answers are: encoding, anyone; hashing, nobody; encryption, whoever has the key; signing, nothing to reverse, because the data was never hidden.

First: everything is bytes

Crypto functions don't work on text; they work on bytes (numbers from 0 to 255). So the first step is always turning text into bytes, almost always with UTF-8: "hi" becomes the two bytes 104 105.

Crypto output is also bytes, and most of them aren't printable characters. To show them in a log, a URL or JSON, we write them as text with an encoding:

  • Hexadecimal: two characters per byte, 68 69 → "6869". Easy to read, twice as long.
  • Base64: four characters per three bytes, so about a third longer than the raw bytes. Compact, common in HTTP headers and JSON. The base64url variant swaps +/ for -_ so it's safe inside URLs.
Encoding: a format change anyone can undo
package main

import (
    "encoding/base64"
    "encoding/hex"
    "fmt"
)

func main() {
    b := []byte("hunter2") // UTF-8 bytes
    fmt.Println(hex.EncodeToString(b))            // 68756e74657232
    s := base64.StdEncoding.EncodeToString(b)
    fmt.Println(s)                                 // aHVudGVyMg==

    back, _ := base64.StdEncoding.DecodeString(s) // no key needed
    fmt.Println(string(back))                      // hunter2
}

Now all four, on your input

Two more words before the widget. Plaintext is readable data; Ciphertext is its encrypted, random-looking form. And a key is just a secret random value, usually 32 bytes, that the encryption or signing function needs. Everything below is real cryptography, computed in your browser by the Web Crypto API:

Four tools, one input
Real cryptography, running in your browser. Type anything; every row recomputes.
Encode (Base64)who can reverse it: anyone
Changes the format so bytes fit in text. Not a secret at all.
…
Hash (SHA-256)who can reverse it: nobody (only guess & compare)
A fixed-size fingerprint. Same input → same hash, always.
…
Encrypt (AES-256-GCM)who can reverse it: only the key holder
Scrambled with a secret key. Different every time (a fresh random value is mixed in).
…
Sign (Ed25519)who can reverse it: not hidden: the text travels as-is
Proves who wrote it and that it wasn't changed. Anyone can check; only the private key can create.
…

Things to notice:

  • Encoding gives the same output every time and decodes instantly. If you type hunter2, the encoded value is exactly the one in the Go snippet above.
  • Hashing also gives the same output every time, but nothing turns it back. "Try to reverse it" just hashes a list of common passwords and compares. Type hunter2 or password and watch it get "cracked" anyway. That's §08's whole problem.
  • Encryption gives a different output every time you type, even for the same text (each run mixes in a fresh random value, called a nonce, §06). Only the right key decrypts it.
  • Signing doesn't hide the text at all. It produces a signature that anyone can check with the public key and that breaks if a single letter changes.

Which one do I need?

You want to…UseNot
put bytes in JSON, a URL or a headerencoding (base64url, hex)(it's not security)
check data wasn't changed by accidenthash (SHA-256)MD5 if attackers are involved
check data wasn't changed by an attacker, with a shared secretHMAC (§04)a plain hash
keep data secret, and read it back laterencryption (AES-GCM, §05)base64, hashing
let anyone check that you produced somethingsignature (Ed25519, §07)HMAC (then everyone who checks can also forge)
store passwordspassword hash (Argon2id; bcrypt in older systems, §08)encryption, fast hashes
Foundations§03

Hash functions: fingerprints for data

In one sentenceA cryptographic hash turns any input into a short, fixed-size fingerprint. It's easy to compute, impossible to reverse, and nobody can find two inputs with the same fingerprint.

A hash function is any function that maps data to a fixed-size number. Hash maps use quick, simple ones (Hash maps §02). A cryptographic hash is a hash function built to hold up against an attacker. The standard one is SHA-256: whatever you feed it, a 3-byte string or a 3-GB video, you get back 32 bytes (64 hex characters).

SHA-256 in Go and Node
package main

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

func main() {
    a := sha256.Sum256([]byte("transfer $100 to bob"))
    b := sha256.Sum256([]byte("transfer $900 to bob")) // one character different
    fmt.Println(hex.EncodeToString(a[:]))
    fmt.Println(hex.EncodeToString(b[:]))
    fmt.Println(len(a)) // 32: always 32 bytes, whatever the input size
}
// 3a595622671ff3313dd06ddba1efbf40ef0dcfcd5f9bc6874221f916e04ebf78
// eda360de01f0246a4ae9a4013c269db4928703b59c81ee8f4938b6d61b1e5dd7

The three promises

A cryptographic hash promises three things. Each one is what makes a particular use safe:

  1. Deterministic. Same input, same hash, on every machine, forever. That's why you can publish a hash and let others check their copy.
  2. One-way (preimage resistance). Given only the hash, there's no way to work back to the input. The only option is to guess inputs and hash each one, which is fast for short or common inputs (§08 is about exactly that).
  3. Collision-resistant. Nobody can find two different inputs with the same hash. Such pairs exist (there are infinitely many inputs and "only" 2²⁵⁶ hashes), but finding one should take longer than the age of the universe. A pair like that is a collision.

A fourth property makes these possible: the avalanche effect. Change one bit of input and about half the output bits flip, in a pattern you can't predict. Try it: each click changes a single bit (1 = 00110001 becomes 0 = 00110000).

The avalanche effect
Click any character to flip ONE bit of it. Then compare the two SHA-256 hashes, bit by bit.
byte 10: 00110001 “1” → 00110000 “0” (only the last bit differs)
original
one bit flipped
256 output bits
0 / 256 bits changed

The average lands near 128, half of 256. There's no "nearby" hash: a tiny edit gives a fingerprint that looks completely unrelated, so you can't learn anything about the input by trying inputs that are "close".

Why MD5 and SHA-1 are retired

MD5 / SHA-1 were the standards of the 1990s. Both have been broken for collisions: researchers can now produce two different files with the same hash. MD5 collisions were demonstrated in 2004 and take seconds on a laptop today. For SHA-1, Google and CWI published two different PDF files with the same SHA-1 in 2017 ("SHAttered").

A collision is enough to do real damage, because many systems vouch for a file by its fingerprint. Picture a trusted company that stamps "approved" on the fingerprint of a harmless file. If the attacker has prepared a harmful file with the same fingerprint, that approval now covers the harmful file too. In 2008 researchers did exactly this to the companies that vouch for websites (certificate authorities, which approve which server may use which name): with an MD5 collision they got a fake approval accepted by browsers. That's why browsers stopped accepting MD5, and later SHA-1, for this job. (How that approval, a digital signature, works is §07.)

What hashes are actually used for

  • Download checks. A project publishes SHA256SUMS next to its files. You hash what you downloaded and compare. It catches corruption and swapped mirrors, but if the attacker controls the website, they can change the file and the published hash together. That's why releases are also signed (§07).
  • Content addressing. Git names every file and commit by its hash (SHA-1, with SHA-256 repositories now supported). Docker images are pulled by sha256:… digest. Same content → same name, and a changed byte → a different name.
  • Trees of hashes. A Merkle tree hashes hashes, so one 32-byte root summarises gigabytes, and two machines can find exactly which block differs. See Trees §11.
  • Detecting disk and network corruption. Databases and file formats store a checksum next to each block. They often use a faster non-cryptographic one like CRC32, because random bit-flips aren't trying to fool you.
Practitioner§04

HMAC: a hash with a key

In one sentenceAn HMAC is a fingerprint only people who know the secret key can make. Attach it to a message, and anyone holding the key can check that the message is unchanged and came from someone else with the key.

§03 ended with a problem: an attacker who changes a message can just recompute its SHA-256. The fix is to mix in a secret the attacker doesn't have. The resulting short value is a MAC (message authentication code), and the standard way to build one from a hash function is HMAC.

A worked example. Your server sets a cookie user=42&role=member and wants to notice if the browser edits it to role=admin. You send user=42&role=member plus HMAC-SHA256(key, "user=42&role=member"). The user can change the text, but without the key they can't compute the matching tag, so the server rejects it.

Sign and verify with HMAC-SHA256
package main

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

var key = []byte("server-only-secret-32-bytes-long") // real code: 32 random bytes from a secrets manager

func sign(msg []byte) string {
    mac := hmac.New(sha256.New, key) // a hash function + a key
    mac.Write(msg)
    return hex.EncodeToString(mac.Sum(nil))
}

func verify(msg []byte, tagHex string) bool {
    got, err := hex.DecodeString(tagHex)
    if err != nil {
        return false
    }
    mac := hmac.New(sha256.New, key)
    mac.Write(msg)
    return hmac.Equal(mac.Sum(nil), got) // ← constant-time compare, never ==
}

func main() {
    msg := []byte("user=42&role=member")
    tag := sign(msg)
    fmt.Println(verify(msg, tag))                          // true
    fmt.Println(verify([]byte("user=42&role=admin"), tag)) // false: message changed
}

Why not just sha256(key + message)?

It looks equivalent, and for SHA-256 it's broken. SHA-256 processes data in blocks, and its output is its internal state after the last block. An attacker who knows sha256(key + msg) can continue hashing from that state: they can compute a valid sha256(key + msg + padding + "&role=admin") without knowing the key. (The padding is a few bytes SHA-256 always adds at the end of its input; the attacker can work them out from the message length.) This is a length-extension attack.

HMAC hashes twice: first the key together with the message, then the key again together with that first result. What you publish is the output of the second hash, which is not a state the attacker can continue from, so the hole is closed. Use the HMAC function your library gives you; don't glue keys and messages together yourself.

HMAC vs signatures

HMAC uses one shared secret: whoever can check a tag can also create one. That's perfect when the same party does both (your server signing its own cookies) or when two parties trust each other (you and a customer sharing a webhook secret). When many people must check but only you may create, use a signature with a key pair instead (§07).

Webhook signatures are the classic real-world HMAC. The full receiver, with timestamps and replay protection, is in API design §10.

The comparison is part of the security

Look again at the last line of verify. Why not tag === want?

A normal string comparison checks one character at a time and returns at the first difference. A guess with the first 3 characters right takes a few nanoseconds longer than a guess with none right. One measurement is lost in network noise, but averaging thousands of them makes the difference visible. An attacker can then recover a valid tag one character at a time instead of guessing all of it at once. That's a timing attack.

Try it against a simulated server. Each probe tries all 16 hex digits at the next position, measures each one 300 times, and keeps the slowest:

Guess a secret token from response times
Simulated: a server checks a 6-character API token. You can only send guesses and time the replies.
🕵️ what you've worked out
••••••
0 requests sent · brute force would need up to 16⁶ = 16,777,216
🖥️ server code
for (i = 0; i < 6; i++)
  if guess[i] != token[i]:
    return false   ← exits early
return true

With the early-exit compare you recover 6 characters in 6 × 16 = 96 kinds of guess instead of up to 16⁶ ≈ 16.8 million. Switch to constant-time and the bars are flat: the server always does the same amount of work, so time says nothing.

Practitioner§05

Symmetric encryption: AES-GCM

In one sentenceTo keep data secret and notice tampering, encrypt it with an AEAD cipher, AES-256-GCM in practice: a 32-byte key, a fresh 12-byte nonce per message, and a 16-byte tag that makes any change fail loudly.

Symmetric encryption means one key both locks and unlocks. It's what you use when your own app needs to read the data back later: an OAuth refresh token in a database column, a file in object storage, a session payload. It's fast: AES has dedicated CPU instructions and encrypts gigabytes per second.

AES itself is a block cipher: it scrambles exactly one 16-byte block. To encrypt messages of any length you wrap it in a mode. The mode matters as much as AES does, and the lab below shows why.

Encryption without a tag can be edited

Older modes such as AES-CTR and AES-CBC only hide data. They don't detect changes, and worse, an attacker can often change the ciphertext so the decrypted plaintext changes in a way they control. That's called malleable encryption. Try it: the attacker can't read the message, but knows its format.

Flip bits in a ciphertext
The server encrypted “to=bob&amount=100”. You are the attacker: you can't read it, but you can change bytes before it arrives.
ciphertext on the wire (click a byte to flip its lowest bit)17 bytes
Highlighted: the 3 bytes that encrypt “100”.

In CTR mode, ciphertext = plaintext XOR a stream of key-derived bytes. Flip a bit in the ciphertext and the same bit flips in the plaintext. 1 is 0x31 and 9 is 0x39; they differ by 0x08, so XOR-ing that byte with 0x08 turns 100 into 900. No key needed.

GCM adds a tag: a 16-byte MAC over the ciphertext, made with the key. Decryption checks the tag before returning anything, so a modified message produces an error instead of plaintext. Encryption with a built-in tag is called authenticated encryption (AEAD). Use an AEAD for everything new; the common ones are AES-GCM and ChaCha20-Poly1305 (fast on phones without AES instructions).

AES-256-GCM in Go and Node

The format below stores nonce ‖ ciphertext ‖ tag (‖ = "followed by") in a single byte string, so you have one value to save in a database column. The nonce is a 12-byte value that must be different for every message encrypted with the same key; it isn't secret, so it's stored right next to the ciphertext. §06 explains why it matters so much.

Encrypt, decrypt, and catch tampering
package main

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

// encrypt returns nonce || ciphertext || tag, all in one byte slice.
func encrypt(key, plaintext []byte) ([]byte, error) {
    block, err := aes.NewCipher(key) // 32-byte key → AES-256
    if err != nil {
        return nil, err
    }
    gcm, err := cipher.NewGCM(block)
    if err != nil {
        return nil, err
    }
    nonce := make([]byte, gcm.NonceSize()) // 12 bytes
    rand.Read(nonce)                       // ← fresh random nonce, every single time
    // Seal appends ciphertext + 16-byte tag to its first argument (here: the nonce).
    return gcm.Seal(nonce, nonce, plaintext, nil), nil
}

func decrypt(key, box []byte) ([]byte, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return nil, err
    }
    gcm, err := cipher.NewGCM(block)
    if err != nil {
        return nil, err
    }
    if len(box) < gcm.NonceSize() {
        return nil, errors.New("ciphertext too short")
    }
    nonce, ct := box[:gcm.NonceSize()], box[gcm.NonceSize():]
    return gcm.Open(nil, nonce, ct, nil) // checks the tag BEFORE returning anything
}

func main() {
    key := make([]byte, 32)
    rand.Read(key)

    box, _ := encrypt(key, []byte("card=4242424242424242"))
    fmt.Println(len(box)) // 49 = 12 nonce + 21 data + 16 tag

    pt, err := decrypt(key, box)
    fmt.Println(string(pt), err) // card=4242424242424242 <nil>

    box[20] ^= 1 // flip one bit in the ciphertext
    _, err = decrypt(key, box)
    fmt.Println(err) // cipher: message authentication failed
}

Keys are not passwords

An AES-256 key is 32 random bytes from the secure generator. A password like Summer2024! is not a key: it's short, guessable, and the wrong size. Turning one secret into a key of the right size is the job of a key derivation function, and which one depends on how strong the input is:

InputKDFWhy
a strong random secret (a master key, or the shared secret from a key exchange, §07)HKDFthe input is already unguessable; just split it into keys per purpose
a human password or passphraseArgon2id, scrypt, or PBKDF2 (when FIPS rules require it)must be slow so guessing is expensive (§08)
Deriving keys
// HKDF (crypto/hkdf, Go 1.24+): one master secret → independent keys per purpose.
encKey, _ := hkdf.Key(sha256.New, master, nil, "app v1: encrypt files", 32)
macKey, _ := hkdf.Key(sha256.New, master, nil, "app v1: sign cookies", 32)

// Password → key (golang.org/x/crypto/argon2): slow, salted, memory-hard.
salt := make([]byte, 16)
rand.Read(salt) // store the salt next to the encrypted data
fileKey := argon2.IDKey([]byte("my laptop passphrase"), salt, 3, 64*1024, 4, 32)

The text label (the "info" argument) is what makes the two HKDF keys different. Never use the same key for two purposes: if the cookie key leaks through a bug in cookie code, the file key stays safe. In both snippets, master is a 32-byte secret from the secure random generator. Where that master key lives (a KMS, a secrets manager) is the subject of Secrets Management.

Practitioner§06

Nonces, and why reusing one is catastrophic

In one sentenceAES-GCM turns your key and nonce into a stream of random-looking bytes and XORs it with the message. Same key + same nonce = same stream, and two messages XOR-ed with the same stream can be pulled apart without the key.

A nonce ("number used once") is the only thing that makes two encryptions under the same key different. The rule is absolute: never encrypt two messages with the same key and the same nonce. To see why, you need to see what GCM does inside.

Inside AES-GCM: keystream, XOR, tag
Bytes are shortened to 4 so they fit; real blocks are 16 bytes.
Code
1// gcm.Seal, simplified (pseudocode)
2counter := 2 // 1 is kept for the tag
3for each 16-byte block p {
4 ks := AES(key, nonce||counter)
5 c := p XOR ks
6 output(c)
7 counter++
8}
9tag := GHASH(c…) XOR AES(key, nonce||1)
10output(tag)
Keystream generatorAES as a scrambler
key = 9f 1c … (secret)
nonce = 4a 07 e2 … (12 bytes, public)
Plaintext ⊕ keystream
empty
Sent on the wire
empty
Two inputs: the secret key and a nonce, a 12-byte number used once. The nonce isn't secret; it travels next to the ciphertext. A counter starts at 2 (counter 1 is saved for the tag).
1 / 6

The key insight from that walkthrough: the keystream depends only on the key, the nonce and the counter, not on the message. So two messages with the same key and nonce are XOR-ed with exactly the same keystream bytes.

The XOR trick: worked through

XOR has two properties that matter here: x ⊕ x = 0, and x ⊕ 0 = x. Write ks for the shared keystream:

EquationWhat it means
c1 = p1 ⊕ ksmessage 1 encrypted
c2 = p2 ⊕ ksmessage 2 encrypted, same nonce
c1 ⊕ c2 = p1 ⊕ ks ⊕ p2 ⊕ ks = p1 ⊕ p2the two ks cancel out

The attacker never learns the key, but now holds p1 ⊕ p2, the two plaintexts mixed together with no secret in them. Real messages have a lot of known plaintext: JSON field names, HTTP headers, a fixed format. Guess p1 (or part of it) and p1 ⊕ (p1 ⊕ p2) = p2 falls out. Try it with real AES-GCM:

Reuse a nonce, lose the secret
Real AES-256-GCM in your browser, one key. The attacker sees only the two ciphertexts, and guesses message 1 (say, from a known format).

For GCM it's even worse than leaking plaintext. Two messages under one nonce also leak enough about the internal authentication key that an attacker can forge valid tags for messages of their choice. One reused nonce can break both secrecy and integrity for every message under that key.

✕ Vulnerable: a fixed nonce (don't do this)
nonce := make([]byte, 12) // ✕ BUG: all zeros, the same for every message
c1 := gcm.Seal(nil, nonce, []byte("PAY ALICE $0010"), nil)
c2 := gcm.Seal(nil, nonce, []byte("PAY BOBBY $9000"), nil)

// The attacker has only c1 and c2 (no key) and guesses p1:
p1 := []byte("PAY ALICE $0010")
leak := make([]byte, len(p1))
for i := range leak {
    leak[i] = c1[i] ^ c2[i] ^ p1[i] // c1⊕c2 = p1⊕p2, so ⊕p1 leaves p2
}
fmt.Println(string(leak)) // PAY BOBBY $9000

The fix is the code from §05: a fresh random nonce from the secure generator for every Seal/createCipheriv, stored with the ciphertext.

How nonces end up repeated in real code

  • A constant or a zero nonce, as above, often copied from a test or a tutorial.
  • A counter that resets: stored in memory, so every restart begins at 0 again, or two servers each running their own counter with the same key.
  • A nonce derived from the data (e.g. a hash of the user ID): the same user's data encrypted twice gets the same nonce.
  • Too many messages under one key. A random 12-byte nonce is 96 bits, and by the birthday paradox two random nonces become likely to collide long before 2⁹⁶. NIST's guidance caps random-nonce GCM at 2³² messages per key (about 4 billion) to keep the chance negligible. Go's documentation for NewGCMWithRandomNonce states the same limit.
Practitioner§07

Key pairs: signatures and key agreement

In one sentenceA key pair splits the key in two: the private key stays with you and creates signatures; the public key goes to everyone and checks them. That lets the whole world verify something only you could have made.

With HMAC (§04), checker and signer share the same secret, so anyone who can check a tag can also forge one. That's a problem when you ship software to a million laptops: each one needs to check your updates, and none of them may be able to create an update. Public-key cryptography solves it with a key pair.

Signatures: the wax seal

A digital signature is computed from the data and the private key. Anyone with the public key can verify it. Verification answers two questions at once: was this made by the private-key holder, and is the data exactly what they signed? Here's that flow for a software release. You play the attacker in the middle:

Sign it, then try to cheat
Real Ed25519 signatures in your browser. The verifier only knows the publisher's PUBLIC key.
🏢 publisher
holds the private key 🔒
🕵️ on the way (Eve)
release v2.4.1 sha256=3a59…bf78
sig: —
💻 your updater
has the publisher's public key 🔑

Changing the text breaks the signature. Re-signing with her own key doesn't help Eve either, because the updater only trusts the publisher's public key. She would need the publisher's private key, which never left their machine.

The modern default algorithm is Ed25519: 32-byte public keys, 64-byte signatures, fast, and designed so the easy way to use it is the safe way. You'll meet it in SSH keys (ssh-ed25519 AAAA…), Git commit signing and package registries.

Ed25519: sign and verify
package main

import (
    "crypto/ed25519"
    "crypto/rand"
    "fmt"
)

func main() {
    pub, priv, err := ed25519.GenerateKey(rand.Reader) // the key pair
    if err != nil {
        panic(err)
    }
    msg := []byte("release v2.4.1 sha256=3a5956…")

    sig := ed25519.Sign(priv, msg)  // only the private key can do this
    fmt.Println(len(pub), len(sig)) // 32 64 (len(priv) is 64: Go stores the 32-byte seed + the public key)

    fmt.Println(ed25519.Verify(pub, msg, sig))                                  // true
    fmt.Println(ed25519.Verify(pub, []byte("release v2.4.1 sha256=evil"), sig)) // false
}

Notice what the message contains: a SHA-256 hash of the release file. Signing a hash of the file is the standard pattern. The signature proves the publisher vouches for that hash, and the hash (§03) proves your download matches it. Together they close the gap from §03, where an attacker could swap both the file and its published hash.

Key agreement: a shared secret in public

Signatures prove who. To hide data between two parties, they need a shared symmetric key (§05), and they may have never met. Diffie-Hellman key agreement solves that: each side keeps a private value, publishes a mix of it, and combines the other side's mix with its own private value. Both end up with the same secret; an eavesdropper who saw both mixes can't compute it.

Diffie-Hellman with paint
Agree on a secret colour while Eve watches every message. Step through it.
👩 Alice
public yellow
agreed in public
📡 public wireEve sees all of this
public yellow
👨 Bob
public yellow
agreed in public
step 1 / 6
Alice and Bob have never met, and every message between them goes over a wire Eve can see. First they agree, in public, on a common paint colour: yellow. Eve sees it too, and that's fine.

Today this is done with elliptic curves, usually a curve called X25519; Diffie-Hellman on an elliptic curve is called ECDH. In Go it's crypto/ecdh, in Node crypto.diffieHellman(). The full story, with numbers and code, is in TLS §03. The shared secret is then fed through HKDF (§05) to make the actual AES keys.

"Encrypt with the public key": hybrid encryption

Some key pairs (RSA) can also encrypt: anyone locks with the public key, only the private key unlocks. In practice nobody encrypts data directly with RSA, because it's slow and limited to a few hundred bytes. Instead, systems use hybrid encryption:

  1. Make a fresh random AES key.
  2. Encrypt the data with AES-GCM (fast, any size).
  3. Protect just that 32-byte AES key for the recipient's public key (with RSA in its OAEP mode, or with an ECDH exchange against their public key).
  4. Send both.

Tools such as age, PGP and HPKE (RFC 9180, used inside newer TLS features) all work this way. You'll almost never build it yourself: §11 shows which library to reach for.

Practitioner§08

Password hashing: slow on purpose

In one sentenceA stolen password database is attacked by guessing: hash a guess, compare, repeat. So a password hash must be slow (to make each guess expensive), salted (so each user is attacked separately) and ideally memory-hard (so GPUs don't help much).

The attack you are defending against

You store hash(password) and, at login, compare hash(attempt) to it. Now assume the table leaks; it happens through SQL injection, a stray backup, a compromised admin laptop. Hashes can't be reversed (§03), so the attacker does the only thing left: guess passwords, hash each guess, and look for matches. That's brute force, and when the guesses come from leaked-password lists and common patterns first, a dictionary attack.

The attacker's speed is simply guesses per second, and a fast hash is a gift. SHA-256 is designed to be fast: one core of the machine this article was built on does about 13 million per second, and a single gaming GPU does about 22 billion. With 8 lowercase letters (26⁸ ≈ 209 billion possibilities), that's under ten seconds to try them all.

Salts: one attack per user, not one for everybody

Without a salt, everyone with the password hunter2 has the same hash. The attacker hashes each guess once and checks it against every user at the same time, or skips hashing altogether with a rainbow table, a precomputed lookup from hash back to password.

A salt is a random value (16 bytes is typical) generated per user and stored next to their hash: hash(salt + password). Now the same password gives different hashes for different users, precomputed tables are useless, and the attacker must repeat all the work for each user separately. Salts aren't secret; they only need to be unique.

Try both attacks, with and without salts:

What a salt changes
Real SHA-256 in your browser. alice and bob picked the same password. The attacker has stolen this table.
users table (stolen)

Salting alone is not enough, though: salted SHA-256 still runs at billions of guesses per second against each single user. The real defence is cost.

Slow on purpose

Password hashing functions run their inner work thousands of times, controlled by a cost factor. A login that takes 50 ms is invisible to your user, who logs in once. For an attacker who needs billions of guesses, the same 50 ms per guess is a disaster. Pick a password, pick an attacker, and compare:

How long to try every possible password?
Worst case for the attacker: they try every candidate. On average they're done in half this time.
password
attacker
2.09 × 10^11 candidates
MD5
1.3 seconds · 164.1 billion/s
SHA-256
9.5 seconds · 22.0 billion/s
PBKDF2-SHA256, 600k
164 days · 14,762/s
bcrypt, cost 10
1.2 years · 5,750/s
bcrypt, cost 12
4.6 years · 1,438/s
scrypt, N=2¹⁷ (128 MiB)
memory-hard: no fair GPU number
Argon2id, 19 MiB, t=2
memory-hard: no fair GPU number
Argon2id, 64 MiB, t=3
memory-hard: no fair GPU number
CPU rates: measured for this article with Go's standard library on one core of a 2.3 GHz Intel Xeon; a dedicated cracking tool on the same core is several times faster for MD5 and SHA-256. GPU rates: hashcat's published benchmark for one RTX 4090 (2022), scaled to each cost setting, so treat them as rough. Scrypt and Argon2id need megabytes of memory per guess, which is exactly what limits GPUs; we don't show a number we can't back up. Bars are on a log scale.

Things to try: "8 letters + digits" against MD5 on 1 GPU (around 20 minutes) and against bcrypt cost 12 (thousands of years). Then "one of the 10,000 most common": every algorithm loses within seconds to hours. No hash saves a terrible password; that's what breached-password checks and rate limiting are for.

Measured on one machine

The CPU numbers in the widget came from these runs, all on one core of a 2.3 GHz Intel Xeon (Go 1.24 with golang.org/x/crypto; Node 22 timings were within about 20%). Your hardware will differ; measure on your own servers. These are what your server pays per login; a dedicated cracking tool running on the same core is several times faster for MD5 and SHA-256, so read the fast-hash rows as a lower bound for an attacker. Memory sizes use binary units: 1 MiB = 1,024 KiB = 1,048,576 bytes.

Algorithm and settingsTime per hashGuesses/second (Go stdlib, 1 core)
MD50.09 µs~11 million
SHA-2560.07 µs~13 million
PBKDF2-SHA256, 600,000 iterations89 ms~11
bcrypt, cost 1055 ms~18
bcrypt, cost 12211 ms~4.7
bcrypt, cost 14839 ms~1.2
scrypt, N=2¹⁷, r=8, p=1 (128 MiB)328 ms~3
Argon2id, 19 MiB, t=2, p=124 ms~42
Argon2id, 64 MiB, t=3, p=1134 ms~7.5

Each +1 of bcrypt cost doubles the time (55 → 104 → 211 → 427 → 839 ms for costs 10–14). Argon2id at 24 ms looks "fast" here, but each guess also needs 19 MiB of memory, and that is what makes it expensive for attackers.

Memory-hard: taking the GPU out of the fight

A GPU has thousands of cores, but only a little fast memory per core. bcrypt needs about 4 KB per guess, so a GPU still runs thousands in parallel. scrypt and Argon2id are memory-hard: each guess must fill and re-read tens of megabytes, so a GPU can run only a handful at once and loses most of its advantage. The same goes for custom cracking chips.

bcrypt in Go and Node

The output is a self-describing string: algorithm version, cost, salt and hash in one 60-character value. Store it in a single column; verification reads the salt and cost back out of it.

bcrypt: hash at sign-up, compare at login
package main

import (
    "errors"
    "fmt"

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

func main() {
    // At sign-up: hash, then store the whole string in the users table.
    hash, err := bcrypt.GenerateFromPassword([]byte("hunter2"), 12) // cost 12
    if err != nil {
        panic(err)
    }
    fmt.Println(string(hash)) // $2a$12$<22-char salt><31-char hash>, 60 chars total

    // At login: re-hash the attempt with the salt and cost stored in `hash`.
    err = bcrypt.CompareHashAndPassword(hash, []byte("hunter3"))
    fmt.Println(errors.Is(err, bcrypt.ErrMismatchedHashAndPassword)) // true → wrong password
    fmt.Println(bcrypt.CompareHashAndPassword(hash, []byte("hunter2"))) // <nil> → correct

    h2, _ := bcrypt.GenerateFromPassword([]byte("hunter2"), 12)
    fmt.Println(string(hash) == string(h2)) // false: a new random salt each time
}

Argon2id in Go and Node

Node 22 has no built-in Argon2 (very recent Node releases add an experimental crypto.argon2), so use the argon2 npm package. Go's x/crypto/argon2 returns raw bytes, so you build the storable string yourself, in the standard PHC format:

Argon2id with the settings stored in the hash
// OWASP's minimum for Argon2id: 19 MiB of memory, 2 passes, 1 thread.
const memKiB, passes, threads, keyLen = 19 * 1024, 2, 1, 32

var b64 = base64.RawStdEncoding

func hashPassword(pw string) string {
    salt := make([]byte, 16)
    rand.Read(salt) // crypto/rand
    key := argon2.IDKey([]byte(pw), salt, passes, memKiB, threads, keyLen)
    // Store the parameters with the hash, so they can change later.
    return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
        memKiB, passes, threads, b64.EncodeToString(salt), b64.EncodeToString(key))
}

func checkPassword(pw, encoded string) (bool, error) {
    parts := strings.Split(encoded, "$") // ["", "argon2id", "v=19", "m=…,t=…,p=…", salt, key]
    if len(parts) != 6 || parts[1] != "argon2id" {
        return false, errors.New("not an argon2id hash")
    }
    var m, t uint32
    var p uint8
    if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
        return false, err
    }
    salt, err := b64.DecodeString(parts[4])
    if err != nil {
        return false, err
    }
    want, err := b64.DecodeString(parts[5])
    if err != nil {
        return false, err
    }
    got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want)))
    return subtle.ConstantTimeCompare(got, want) == 1, nil // constant time (§04)
}
// hashPassword("hunter2") → $argon2id$v=19$m=19456,t=2,p=1$ZsNUB3KE…$hetMXfex…

Hand-parsing is fine in a demo, but it's also where bugs hide: the Node library writes p before t, which the Sscanf above would reject; it ignores the v= version; and a corrupted row with t=0 or p=0 would make argon2.IDKey panic, so real code must check t >= 1 && p >= 1 before calling it. In production Go code, prefer a small, well-reviewed package that encodes and parses PHC strings, or use bcrypt, whose library does it for you.

Practitioner§09

Storing passwords in practice

In one sentenceStore the algorithm and settings inside every hash, check them at each successful login, and re-hash with today's settings while you briefly hold the real password. That one habit makes every future upgrade painless.

Upgrade on login

You can't re-hash passwords you don't know. But there's one moment you do know a user's password: right after they've typed it in and it checked out. So the login code compares, and if the stored hash used older or cheaper settings, it hashes the password again with the current ones and saves that.

Re-hash after a successful login
type User struct{ Hash string }

const targetCost = 12

// login reports whether pw is right, and upgrades an old hash while it has the plaintext.
func login(u *User, pw string) (bool, error) {
    err := bcrypt.CompareHashAndPassword([]byte(u.Hash), []byte(pw))
    if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
        return false, nil // wrong password
    }
    if err != nil {
        return false, err // corrupted hash in the DB: a bug, not a wrong password
    }
    if cost, _ := bcrypt.Cost([]byte(u.Hash)); cost < targetCost {
        h, err := bcrypt.GenerateFromPassword([]byte(pw), targetCost)
        if err == nil {
            u.Hash = string(h) // ← in real code: UPDATE users SET hash = $1 WHERE id = $2
        }
    }
    return true, nil
}
// A cost-10 hash ($2a$10$…) becomes $2a$12$… after the user's next login.

Pepper: a key the database doesn't have

A pepper is a secret key mixed into every password hash and stored outside the database, in a secrets manager or KMS. Typically you compute HMAC-SHA256(pepper, password) (the HMAC from §04), encode it as base64, and give that to Argon2id or bcrypt. If only the database leaks (the most common case: a backup, an SQL injection), the attacker can't test a single guess without the pepper.

It's an extra layer of defense in depth, not a replacement for slow hashing, and it has a cost: you can't easily change the pepper, because you'd need every user's password to re-hash. Keep a version tag next to each hash (pepper_v=1) so a future rotation can happen user by user at login, just like a cost upgrade.

Rescuing a table of fast hashes

Upgrade-on-login has a weak spot: users who don't log in for a year keep their old hashes, and if those are unsalted MD5, they're the ones a breach exposes. The standard fix is to wrap them immediately: put the old MD5 value through the pepper's HMAC, Argon2id-hash the result, then finish the upgrade at each login. In the code below, pepper(x) means "base64 of HMAC-SHA256 with the pepper key".

Rescuing a table of MD5 hashes
Upgrade everyone at once without knowing a single password, then finish the job as people log in.
Code
1async function login(user, pw) {
2 let ok;
3 if (user.hash.startsWith("$wrapped$")) { // old MD5 → HMAC with pepper → Argon2id
4 ok = await argon2.verify(user.hash.slice(9), pepper(md5hex(pw)));
5 } else {
6 ok = await argon2.verify(user.hash, pw);
7 }
8 if (!ok) return false;
9 if (user.hash.startsWith("$wrapped$") || argon2.needsRehash(user.hash, target)) {
10 await db.setHash(user.id, await argon2.hash(pw, target)); // upgrade now
11 }
12 return true;
13}
users table
alice md5: 5f4dcc3b…
bob md5: 482c811d…
what happens
2014 code, still in production
The starting point: unsalted MD5 hashes. If this table leaks, a GPU tests billions of guesses per second against it. You can't wait for every user to log in before fixing that.
1 / 6

Why the pepper step? Because of password shucking. Many of your users' passwords already sit, as unsalted MD5 or SHA-1 hashes, in breach dumps from other sites. If you stored plain argon2id(md5(pw)), an attacker with your table could skip guessing passwords: they'd feed each leaked MD5 value straight into the Argon2id check, one try per leaked hash instead of billions of guesses, and every hit gives them a fast MD5 hash they can crack on a GPU afterwards. With the HMAC in the middle, those leaked values are useless unless the attacker also steals the pepper.

After the migration job runs, no fast hash is left in the database, even for users who never come back. It's still a halfway state: the old MD5 is inside each wrapped row, so set a deadline, force a password reset for anyone still wrapped, and delete what's left.

bcrypt only reads 72 bytes

bcrypt uses at most the first 72 bytes of the password. Libraries disagree about what to do with the rest:

  • Go's x/crypto/bcrypt refuses: GenerateFromPassword returns bcrypt: password length exceeds 72 bytes.
  • Node's bcrypt package silently ignores everything after byte 72. Two passwords that share the first 72 bytes are treated as the same password (the quiz has a demo).

Seventy-two bytes sounds like plenty, but UTF-8 characters can take up to 4 bytes each, so a passphrase with emoji reaches it in as few as 18 characters. A limit counted in characters doesn't help: 64 characters can be 256 bytes. If you use bcrypt, count bytes:

Reject passwords over 72 bytes (not characters)
var errTooLong = errors.New("password must be at most 72 bytes")

func checkLength(pw string) error {
    if len(pw) > 72 { // len counts BYTES in Go, not characters
        return errTooLong
    }
    return nil
}

pw := strings.Repeat("🔑", 19)                    // 19 characters
fmt.Println(utf8.RuneCountInString(pw), len(pw)) // 19 76
fmt.Println(checkLength(pw))                     // password must be at most 72 bytes
fmt.Println(checkLength("mật khẩu của tôi"))     // <nil> (23 bytes)

The simpler option for new systems: use Argon2id, which has no 72-byte limit.

Traps at the login endpoint

  • Don't reveal which usernames exist through timing. If the user doesn't exist, return quickly and the attacker learns that; if they do, you spend 200 ms hashing. Run a comparison against a fixed dummy hash for unknown users so both paths take the same time.
  • Cap the password length, in bytes: 72 for bcrypt, and a few hundred for Argon2id is plenty. Nobody needs a 1 MB password, and accepting one invites slow requests: some password-hash implementations have done extra work per byte, which made huge passwords a cheap way to tie up a server's CPUs.
  • Limit attempts. Slow hashes protect a stolen table; online guessing against your login form needs rate limiting and breached-password checks. The full login flow, sessions and MFA live in AuthN vs AuthZ.
  • Budget the CPU. At 200 ms per hash, one core verifies 5 logins a second. A login spike (or a credential-stuffing attack) needs capacity planning, not a lower cost setting.
Practitioner§10

Randomness: tokens, IDs and keys

In one sentenceAnything an attacker must not guess (keys, nonces, salts, session IDs, reset links, API tokens) comes from the operating system's secure random generator, never from math/rand or Math.random.

Every technique on this page assumes some value is unpredictable: the AES key, the nonce, the salt. If those come from a weak source, everything built on top falls with them.

Two kinds of random

A PRNG (pseudo-random number generator) produces a sequence that looks random from a starting value, the seed. Same seed → same sequence, every time. That's a feature for games, simulations and tests. But anyone who learns the seed, or sees enough outputs to work out the generator's internal state, can predict every past and future value.

A CSPRNG is built so that even after seeing millions of outputs, you can't predict the next one. It's continuously fed with unpredictable events by the operating system kernel. Every language exposes it:

Predictable (never for secrets)Secure
Gomath/rand, math/rand/v2crypto/rand: rand.Read, rand.Text() (Go 1.24+)
NodeMath.random()crypto.randomBytes, crypto.randomUUID, crypto.randomInt
BrowserMath.random()crypto.getRandomValues, crypto.randomUUID

The attack: guess the seed

A classic bug: seed a PRNG with the current time, then generate a password-reset token. The token looks random (32 hex characters), but every bit of it came from the seed. If the attacker knows roughly when the token was made (they triggered the reset themselves), there are only a few thousand possible seeds to try.

Predict a password-reset token
Simulated: the server just e-mailed a reset token to the victim. You know it was sometime in the last hour.
🖥️ server code
seed = unixSeconds()          // ✕
rng = PRNG(seed)
token = rng.bytes(16).hex()
token in the victim's inbox (hidden from you)
🕵️ your attack
For every second in the last hour: seed the same PRNG, generate a token, try it.
0 / 3,600 seeds tried

The same thing happens without an explicit seed. V8's Math.random (the engine in Chrome and Node) uses a fast generator called xorshift128+; given a handful of consecutive outputs, researchers have recovered its internal state and predicted what comes next.

✕ predictable vs ✓ secure tokens
package main

import (
    "crypto/rand"
    "encoding/hex"
    "fmt"
    mrand "math/rand"
    "time"
)

// ✕ Predictable: anyone who can guess the second you called it can rebuild the token.
func weakToken() string {
    r := mrand.New(mrand.NewSource(time.Now().Unix()))
    b := make([]byte, 16)
    r.Read(b)
    return hex.EncodeToString(b)
}

// ✓ Unpredictable: bytes from the operating system's CSPRNG.
func strongToken() string {
    b := make([]byte, 32) // 256 bits
    rand.Read(b)          // crypto/rand: never fails on Go 1.24+ (it crashes instead)
    return hex.EncodeToString(b)
}

func main() {
    fmt.Println(weakToken() == weakToken()) // true: same second → same "random" token
    fmt.Println(strongToken())
    fmt.Println(rand.Text()) // Go 1.24+: e.g. 5PM2UOCUTOU7ETYMEPEWM2P6TQ (128+ bits)
}

How many random bits is enough?

Count unpredictability in bits of entropy: n random bits means 2ⁿ equally likely values. A 6-digit code has about 20 bits (10⁶ ≈ 2²⁰), which is fine only if attempts are tightly limited (3 tries, then a new code). Anything an attacker can guess offline, or try at volume, needs at least 128 bits: 16 random bytes, or 22 base64url characters.

Staff-level§11

Staff-level: libraries, keys, agility, quantum

In one sentenceYour job is not to pick ciphers; it's to pick a well-reviewed library that picked them for you, keep keys out of the application's hands, and tag everything with a version so you can change your mind later.

"Don't roll your own" means more than "don't invent AES"

Almost nobody invents a cipher. The bugs come from assembling good primitives yourself: a fresh AES-GCM key but a counter nonce that resets on deploy (§06); a correct HMAC compared with === (§04); encryption and a separate MAC glued together in the wrong order (an AEAD makes that choice for you, §05); an RSA key used both to sign and to decrypt; a JWT library that trusts the alg field the attacker sent. Each piece is fine; the combination isn't.

So the rule a staff engineer enforces is: application code calls high-level operations ("seal this", "open this", "hash this password"), and the few lines that choose algorithms live in one reviewed module.

Libraries to reach for

High-level libraries make the safe choice the only choice:

NeedReach forWhy
Encrypt blobs / records with a keylibsodium secretbox / crypto_aead_xchacha20poly1305 (bindings for Node and Go); Go's x/crypto/nacl/secretbox24-byte nonces, big enough to pick at random safely; one function call
Encrypt files for a person or a server's public keyage (filippo.io/age in Go, age CLI)modern hybrid encryption (§07), small and audited
Multi-language teams, key rotation built inGoogle Tink (Go, Java, C++, Python)keysets with versions and rotation; misuse-resistant APIs
Passwordsx/crypto/bcrypt / argon2, npm argon2 / bcrypt§08
Anything on the wire between servicesTLS (TLS & HTTPS), not your own protocolhandshake, rotation and forward secrecy solved
Tokens with claimsa mainstream JWT/PASETO library, with the algorithm pinnedsee AuthN vs AuthZ

The standard libraries are fine building blocks (crypto/* in Go, node:crypto and Web Crypto in JS). The risk isn't their quality; it's how many decisions they leave to you.

Who holds the keys

The best encryption is worthless if the key sits in the same database dump, a .env file in git, or a log line. The production pattern is envelope encryption: a KMS holds a master key that never leaves its hardware; it encrypts small per-record or per-tenant data keys; your app encrypts data with a data key (AES-GCM, §05) and stores the encrypted data key next to the data. Stealing the database then gets you nothing without also being able to call the KMS as your service.

Storage, access policy, key rotation and auditing of those keys are covered in Secrets Management.

Crypto agility: plan for the next migration

Algorithms get retired (MD5, SHA-1, RSA-1024, 3DES), cost settings go up every few years, and keys leak. Crypto agility means none of that requires a rewrite:

  • Every stored value says how it was made. Password hashes already do ($argon2id$v=19$m=…). Do the same for ciphertexts: a version byte or key ID at the front, e.g. v2:kid=2024-07:<nonce‖ciphertext‖tag>.
  • Readers accept old versions; writers use only the newest. Decrypt anything with a known key ID, always encrypt with the current one, and re-encrypt old records in the background or on access (the same idea as upgrade-on-login, §09).
  • Have a kill switch. Know how you'd answer "this key leaked, re-key everything by Friday" before someone asks.

Post-quantum, honestly

A large enough quantum computer would break today's public-key algorithms: RSA, ECDH/X25519 and Ed25519/ECDSA. It would not meaningfully break AES-256 or SHA-256; at most it halves their effective strength, which 256-bit sizes absorb. Nobody can say when, or whether, such a machine will exist. The near-term worry is "harvest now, decrypt later": recorded traffic encrypted with today's key exchange could be decrypted years from now. That's why post-quantum key exchange is already being deployed: NIST standardised ML-KEM (FIPS 203) and ML-DSA signatures (FIPS 204) in 2024; Go 1.24 added crypto/mlkem and enables the hybrid X25519MLKEM768 key exchange in crypto/tls by default, and current Chrome and Firefox use a hybrid exchange too. For application developers, the practical advice is unexciting: keep TLS libraries up to date, use 256-bit symmetric keys, and build the crypto agility above so you can swap signature algorithms when your platforms do.

Practitioner§12

Pitfalls gallery

#1

Calling encoding “encryption”

Base64, hex and URL-encoding are format changes (§02). An API key that's "base64-encoded for safety" is a plain API key. If it must be secret, keep it in a secrets manager; if it must be unreadable at rest, encrypt it (§05).

#2

Fast hashes for passwords

✕ Don't
// ✕ billions of guesses per second on one GPU
const hash = createHash("sha256").update(salt + pw).digest("hex");
✓ Do
// ✓ slow, salted, memory-hard; settings stored in the string
const hash = await argon2.hash(pw, { type: argon2.argon2id });

Salting a fast hash stops precomputed tables, not guessing (§08).

#3

Encryption without a tag

AES-CTR, AES-CBC and, worst, AES-ECB hide data but don't detect changes, and an attacker can often edit the plaintext through the ciphertext (§05's lab). ECB also encrypts identical blocks identically, so patterns show through. In Node, a createCipheriv("aes-256-cbc", …) in new code is a review comment. Use aes-256-gcm or chacha20-poly1305.

#4

A fixed, zero or counter-that-resets nonce

✕ Don't
nonce := make([]byte, 12) // all zeros, every time
ct := gcm.Seal(nil, nonce, data, nil)
✓ Do
nonce := make([]byte, gcm.NonceSize())
rand.Read(nonce) // crypto/rand, fresh per message
ct := gcm.Seal(nonce, nonce, data, nil)

One repeat under the same key leaks the XOR of two plaintexts and lets attackers forge GCM tags (§06).

#5

Comparing secrets with == or ===

Early-exit comparison leaks how many leading characters were right (§04). Use hmac.Equal / subtle.ConstantTimeCompare in Go and crypto.timingSafeEqual in Node for tags, tokens, API keys and reset codes.

#6

Secrets from Math.random or math/rand

Predictable from a few outputs or a guessed seed (§10). Session IDs, reset tokens, API keys, salts, nonces and keys all come from crypto/rand / crypto.randomBytes / crypto.getRandomValues.

#7

Using a password (or a string) directly as a key

✕ Don't
// ✕ 'secret-passphrase' is not 32 random bytes
const key = Buffer.from("secret-passphrase".padEnd(32, "0"));
createCipheriv("aes-256-gcm", key, nonce);
✓ Do
// ✓ random key from the CSPRNG, kept in a secrets manager / KMS
const key = randomBytes(32);
// or, from a human passphrase: scrypt / Argon2id with a stored salt

Keys come from the CSPRNG; human secrets go through a slow KDF first (§05).

#8

Hashing without a key and calling it a signature

sha256(body) in a header proves nothing: anyone can recompute it after changing the body. Use HMAC with a shared secret (§04) or a signature with a private key (§07). And never build MACs as sha256(secret + message): length extension (§04).

#9

Turning off verification “to make it work”

InsecureSkipVerify: true in Go, rejectUnauthorized: false or NODE_TLS_REJECT_UNAUTHORIZED=0 in Node, or ignoring the result of verify() all reduce the strongest crypto to none: an attacker in the middle just presents their own key. Fix the trust configuration instead (TLS §06).

#10

Keys in the code, the repo or the logs

A key in source code is shared with everyone who has ever cloned the repository, forever (git history). The same goes for keys printed in logs and error messages. See Secrets Management for where they should live and how to rotate one that leaked.

#11

One key for everything

The same key for encrypting files, signing cookies and HMAC-ing webhooks means one leak breaks all three, and some combinations interact badly. Derive a separate key per purpose with HKDF and a label (§05).

#12

No version on stored ciphertexts or hashes

Without a version or key ID you can't tell which key or algorithm made a value, so you can't rotate or upgrade without a big-bang migration. Prefix every stored value (§11).

Practitioner§13

Check yourself

Answer before you click. Each explanation points back to the section that covers it.

QUESTION 1 / 7
The “hidden” API key

A config file says API_KEY=c2tfbGl2ZV81MWZh… with a comment “base64-encoded so it's not in plain text”. Someone copies the file. What do they have?

QUESTION 2 / 7
Storing passwords

You're building sign-up. Which goes into the password_hash column?

QUESTION 3 / 7
Same key, same nonce

A bug encrypts two messages with AES-GCM under the same key and the same nonce. What can an observer learn?

QUESTION 4 / 7
Spot the leak
app.post('/hook', (req, res) => {
  const want = hmacHex(SECRET, req.rawBody);
  if (req.get('X-Signature') !== want) return res.sendStatus(401);
  handle(req.body);
});
QUESTION 5 / 7
Predict the output (Node, bcrypt package)
const a = 'x'.repeat(72) + 'secret-one';
const h = await bcrypt.hash(a, 10);
console.log(await bcrypt.compare('x'.repeat(72) + 'totally-different', h));
QUESTION 6 / 7
Who can check a signature?

Your release is signed with your Ed25519 private key. Who can verify that signature?

QUESTION 7 / 7
Reset tokens

A Node service makes reset tokens with Math.random().toString(36).slice(2). The best fix?

Answered 0 / 7
Foundations§14

Cheat sheet

🔤
Encoding hides nothingBase64 and hex change the format. Anyone can reverse them; no key exists.
🖐️
Hash = fingerprintSHA-256: one-way, fixed size, collision-resistant. Not MD5/SHA-1 where attackers matter.
🏷️
HMAC = hash + shared keyProves a message came from a key holder, unchanged. Compare tags in constant time.
✉️
Encrypt with an AEADAES-256-GCM or ChaCha20-Poly1305: hides the data and makes tampering fail.
🔁
Never reuse a nonceFresh random 12-byte nonce per message, stored with the ciphertext; rotate keys before 2³² messages.
🔏
Private signs, public verifiesEd25519 for new signing keys. Key agreement (X25519) + signatures = TLS.
🐢
Passwords: slow, salted, memory-hardArgon2id (m=19 MiB, t=2, p=1 minimum); bcrypt cost ≥ 10 for existing systems. Upgrade on login.
🎲
Secrets come from the CSPRNGcrypto/rand, crypto.randomBytes, getRandomValues. 128+ bits for tokens.
I want to…GoNode / browser
Hash data (integrity)sha256.Sum256(b)createHash("sha256").update(b).digest() / crypto.subtle.digest("SHA-256", b)
Make / check a MAChmac.New(sha256.New, key) + hmac.EqualcreateHmac("sha256", key) + timingSafeEqual
Encrypt a valueaes.NewCipher → cipher.NewGCM → Seal(nonce, nonce, pt, aad)createCipheriv("aes-256-gcm", key, nonce) + getAuthTag() / crypto.subtle.encrypt({name:"AES-GCM", iv})
Derive keys from a master keyhkdf.Key(sha256.New, secret, salt, info, 32) (Go 1.24+)hkdfSync("sha256", secret, salt, info, 32)
Sign / verifyed25519.Sign(priv, msg) / ed25519.Verify(pub, msg, sig)sign(null, msg, privateKey) / verify(null, msg, publicKey, sig)
Hash a passwordbcrypt.GenerateFromPassword(pw, 12), argon2.IDKey(…) (x/crypto)npm argon2 / bcrypt; built-in crypto.scrypt
Check a passwordbcrypt.CompareHashAndPassword(hash, pw)argon2.verify(hash, pw) / bcrypt.compare(pw, hash)
Random tokenrand.Text() (Go 1.24+) or rand.Read(b) from crypto/randrandomBytes(32).toString("base64url") / crypto.getRandomValues(u8)
SettingValue
AES key32 random bytes (AES-256)
GCM nonce12 bytes, random, unique per key
GCM tag16 bytes (the default; don't shorten it)
Salt16 random bytes per password
Argon2idm ≥ 19 MiB, t ≥ 2, p = 1 (OWASP minimum); more memory if affordable
bcryptcost ≥ 10 (12 common); passwords ≤ 72 bytes, counted in UTF-8 bytes
scryptN = 2¹⁷, r = 8, p = 1
PBKDF2-HMAC-SHA256600,000 iterations (only when FIPS requires it)
Random tokens≥ 128 bits (16 bytes); 256 bits is cheap
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in SecurityAuthN vs AuthZ
Read →