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.
The 30-second version
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
A translation → encoding
The same letter, rewritten in another alphabet. Anyone with a dictionary can read it. No secret involved. Base64, Hexadecimal, UTF-8.
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.
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
| Job | Tool | Section |
|---|---|---|
| Check a download or a file wasn't corrupted | SHA-256 hash | §03 |
| Prove a request or cookie came from your server | HMAC | §04 |
| Store a secret so only your app can read it | AES-GCM encryption | §05–§06 |
| Prove a release, token or message came from you | Ed25519 signature | §07 |
| Store user passwords | Argon2id (bcrypt in older systems) | §08–§09 |
| Make tokens, IDs and keys nobody can guess | the secure random generator | §10 |
Encoding, hashing, encryption, signing
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
base64urlvariant swaps+/for-_so it's safe inside URLs.
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:
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
hunter2orpasswordand 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… | Use | Not |
|---|---|---|
| put bytes in JSON, a URL or a header | encoding (base64url, hex) | (it's not security) |
| check data wasn't changed by accident | hash (SHA-256) | MD5 if attackers are involved |
| check data wasn't changed by an attacker, with a shared secret | HMAC (§04) | a plain hash |
| keep data secret, and read it back later | encryption (AES-GCM, §05) | base64, hashing |
| let anyone check that you produced something | signature (Ed25519, §07) | HMAC (then everyone who checks can also forge) |
| store passwords | password hash (Argon2id; bcrypt in older systems, §08) | encryption, fast hashes |
Hash functions: fingerprints for data
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).
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
// eda360de01f0246a4ae9a4013c269db4928703b59c81ee8f4938b6d61b1e5dd7The three promises
A cryptographic hash promises three things. Each one is what makes a particular use safe:
- Deterministic. Same input, same hash, on every machine, forever. That's why you can publish a hash and let others check their copy.
- 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).
- 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 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
SHA256SUMSnext 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.
HMAC: a hash with a 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.
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:
for (i = 0; i < 6; i++)
if guess[i] != token[i]:
return false ← exits early
return trueWith 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.
Symmetric encryption: AES-GCM
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.
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.
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:
| Input | KDF | Why |
|---|---|---|
| a strong random secret (a master key, or the shared secret from a key exchange, §07) | HKDF | the input is already unguessable; just split it into keys per purpose |
| a human password or passphrase | Argon2id, scrypt, or PBKDF2 (when FIPS rules require it) | must be slow so guessing is expensive (§08) |
// 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.
Nonces, and why reusing one is catastrophic
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.
1// gcm.Seal, simplified (pseudocode)2counter := 2 // 1 is kept for the tag3for each 16-byte block p {4 ks := AES(key, nonce||counter)5 c := p XOR ks6 output(c)7 counter++8}9tag := GHASH(c…) XOR AES(key, nonce||1)10output(tag)
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:
| Equation | What it means |
|---|---|
c1 = p1 ⊕ ks | message 1 encrypted |
c2 = p2 ⊕ ks | message 2 encrypted, same nonce |
c1 ⊕ c2 = p1 ⊕ ks ⊕ p2 ⊕ ks = p1 ⊕ p2 | the 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:
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.
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 $9000The 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
NewGCMWithRandomNoncestates the same limit.
Key pairs: signatures and key agreement
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:
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.
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.
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:
- Make a fresh random AES key.
- Encrypt the data with AES-GCM (fast, any size).
- 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).
- 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.
Password hashing: slow on purpose
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:
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:
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 settings | Time per hash | Guesses/second (Go stdlib, 1 core) |
|---|---|---|
| MD5 | 0.09 µs | ~11 million |
| SHA-256 | 0.07 µs | ~13 million |
| PBKDF2-SHA256, 600,000 iterations | 89 ms | ~11 |
| bcrypt, cost 10 | 55 ms | ~18 |
| bcrypt, cost 12 | 211 ms | ~4.7 |
| bcrypt, cost 14 | 839 ms | ~1.2 |
| scrypt, N=2¹⁷, r=8, p=1 (128 MiB) | 328 ms | ~3 |
| Argon2id, 19 MiB, t=2, p=1 | 24 ms | ~42 |
| Argon2id, 64 MiB, t=3, p=1 | 134 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.
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:
// 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.
Storing passwords in practice
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.
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".
1async function login(user, pw) {2 let ok;3 if (user.hash.startsWith("$wrapped$")) { // old MD5 → HMAC with pepper → Argon2id4 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 now11 }12 return true;13}
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/bcryptrefuses:GenerateFromPasswordreturnsbcrypt: password length exceeds 72 bytes. - Node's
bcryptpackage 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:
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.
Randomness: tokens, IDs and keys
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 | |
|---|---|---|
| Go | math/rand, math/rand/v2 | crypto/rand: rand.Read, rand.Text() (Go 1.24+) |
| Node | Math.random() | crypto.randomBytes, crypto.randomUUID, crypto.randomInt |
| Browser | Math.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.
seed = unixSeconds() // ✕ rng = PRNG(seed) token = rng.bytes(16).hex()
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.
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: libraries, keys, agility, quantum
"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:
| Need | Reach for | Why |
|---|---|---|
| Encrypt blobs / records with a key | libsodium secretbox / crypto_aead_xchacha20poly1305 (bindings for Node and Go); Go's x/crypto/nacl/secretbox | 24-byte nonces, big enough to pick at random safely; one function call |
| Encrypt files for a person or a server's public key | age (filippo.io/age in Go, age CLI) | modern hybrid encryption (§07), small and audited |
| Multi-language teams, key rotation built in | Google Tink (Go, Java, C++, Python) | keysets with versions and rotation; misuse-resistant APIs |
| Passwords | x/crypto/bcrypt / argon2, npm argon2 / bcrypt | §08 |
| Anything on the wire between services | TLS (TLS & HTTPS), not your own protocol | handshake, rotation and forward secrecy solved |
| Tokens with claims | a mainstream JWT/PASETO library, with the algorithm pinned | see 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.
Pitfalls gallery
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).
Fast hashes for passwords
// ✕ billions of guesses per second on one GPU
const hash = createHash("sha256").update(salt + pw).digest("hex");// ✓ 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).
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.
A fixed, zero or counter-that-resets nonce
nonce := make([]byte, 12) // all zeros, every time
ct := gcm.Seal(nil, nonce, data, nil)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).
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.
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.
Using a password (or a string) directly as a key
// ✕ 'secret-passphrase' is not 32 random bytes
const key = Buffer.from("secret-passphrase".padEnd(32, "0"));
createCipheriv("aes-256-gcm", key, nonce);// ✓ 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 saltKeys come from the CSPRNG; human secrets go through a slow KDF first (§05).
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).
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).
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.
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).
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).
Check yourself
Answer before you click. Each explanation points back to the section that covers it.
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?
You're building sign-up. Which goes into the password_hash column?
A bug encrypts two messages with AES-GCM under the same key and the same nonce. What can an observer learn?
app.post('/hook', (req, res) => {
const want = hmacHex(SECRET, req.rawBody);
if (req.get('X-Signature') !== want) return res.sendStatus(401);
handle(req.body);
});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));Your release is signed with your Ed25519 private key. Who can verify that signature?
A Node service makes reset tokens with Math.random().toString(36).slice(2). The best fix?
Cheat sheet
| I want to… | Go | Node / browser |
|---|---|---|
| Hash data (integrity) | sha256.Sum256(b) | createHash("sha256").update(b).digest() / crypto.subtle.digest("SHA-256", b) |
| Make / check a MAC | hmac.New(sha256.New, key) + hmac.Equal | createHmac("sha256", key) + timingSafeEqual |
| Encrypt a value | aes.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 key | hkdf.Key(sha256.New, secret, salt, info, 32) (Go 1.24+) | hkdfSync("sha256", secret, salt, info, 32) |
| Sign / verify | ed25519.Sign(priv, msg) / ed25519.Verify(pub, msg, sig) | sign(null, msg, privateKey) / verify(null, msg, publicKey, sig) |
| Hash a password | bcrypt.GenerateFromPassword(pw, 12), argon2.IDKey(…) (x/crypto) | npm argon2 / bcrypt; built-in crypto.scrypt |
| Check a password | bcrypt.CompareHashAndPassword(hash, pw) | argon2.verify(hash, pw) / bcrypt.compare(pw, hash) |
| Random token | rand.Text() (Go 1.24+) or rand.Read(b) from crypto/rand | randomBytes(32).toString("base64url") / crypto.getRandomValues(u8) |
| Setting | Value |
|---|---|
| AES key | 32 random bytes (AES-256) |
| GCM nonce | 12 bytes, random, unique per key |
| GCM tag | 16 bytes (the default; don't shorten it) |
| Salt | 16 random bytes per password |
| Argon2id | m ≥ 19 MiB, t ≥ 2, p = 1 (OWASP minimum); more memory if affordable |
| bcrypt | cost ≥ 10 (12 common); passwords ≤ 72 bytes, counted in UTF-8 bytes |
| scrypt | N = 2¹⁷, r = 8, p = 1 |
| PBKDF2-HMAC-SHA256 | 600,000 iterations (only when FIPS requires it) |
| Random tokens | ≥ 128 bits (16 bytes); 256 bits is cheap |