Cryptography you don't write yourself
In short: Cryptography comes from the standard library, you do not write it yourself: cryptographically secure random numbers, constant-time comparison and authenticated encryption such as AES-GCM.
Problem
The code somebody already knows
The shop sends a 6-digit code for "forgot password". The same generator also makes the discount codes in the newsletter. The code looks random: 807592, 213904, 566120.
One night somebody changes a buyer’s password and shops with their card. He did not guess a million codes: he tried one. He knew the code before the buyer opened the email.
The generator is new Random(started), and started is the second the shop started.
The idea
Random is a formula, not randomness
Random is a pseudorandom number generator: a formula that, from one number (the seed), computes a sequence that looks random. The same seed gives the same sequence. For games, simulations and tests that is useful. For secrets it is a disaster: whoever knows the seed, or can guess it, knows every next number.
The shop’s seed is the second it started. The attacker knows the shop was restarted in the night: 3600 possibilities. He has his own discount code from the newsletter. He tries every seed until the first code matches his. Then the next code of his copy is the buyer’s code.
For everything that has to be secret and unpredictable (tokens, codes, keys, salts, nonces) you use a cryptographic generator. It takes randomness from the operating system (hardware events, noise) and is built so that its next output cannot be computed from what came before.
See it
The attacker tries every second
static final SecureRandom RANDOM = new SecureRandom();
static String resetToken() { byte[] bytes = new byte[32]; RANDOM.nextBytes(bytes); return Base64.getUrlEncoder().withoutPadding() .encodeToString(bytes);}
static String code() { return String.format("%06d", RANDOM.nextInt(1_000_000));}static String code(Random random) { return String.format("%06d", random.nextInt(1_000_000));}
static String predictNext(String seen, long from, int window) { for (int i = 0; i < window; i++) { Random guess = new Random(from + i); if (code(guess).equals(seen)) { System.out.println("Seed: try " + (i + 1)); return code(guess); } } return null;}static final SecureRandom RANDOM = new SecureRandom();
static String resetToken() { byte[] bytes = new byte[32]; RANDOM.nextBytes(bytes); return Base64.getUrlEncoder().withoutPadding() .encodeToString(bytes);}
static String code() { return String.format("%06d", RANDOM.nextInt(1_000_000));}Step by step, in words
- The shop makes its codes with Random, and the seed is the second it started: 03:40:17. The first code goes into the newsletter, to the attacker as well.
- The next number from the same generator is the password reset code for a buyer. It goes by email.
- The attacker knows the shop restarted in the night. He tries every second from 03:00: one generator per second.
- At try 2418 the first code is the same as his discount code. That is the seed: 03:40:17.
- The next number of his copy is the buyer’s reset code. He changes the password and takes the account.
- The fix: the bytes come from the operating system’s cryptographic generator. There is no seed to guess.
- 3600 tries, or 3 million: no match. The token is 32 random bytes, and even a 6-digit code is no longer predictable.
static String code(Random random) { return String.format("%06d", random.nextInt(1_000_000));}
static String predictNext(String seen, long from, int window) { for (int i = 0; i < window; i++) { Random guess = new Random(from + i); if (code(guess).equals(seen)) { System.out.println("Seed: try " + (i + 1)); return code(guess); } } return null;}long night = 1_793_070_000L; // 27.10.2026, 03:00 UTClong started = night + 2_417; // the shop restarted at 03:40:17Random random = new Random(started);String discount = code(random);String reset = code(random);System.out.println("Discount code from the newsletter (the attacker has it): " + discount);String predicted = predictNext(discount, night, 3_600);System.out.println("Predicted password reset code: " + predicted);System.out.println("The real code sent to the buyer: " + reset);Discount code from the newsletter (the attacker has it): 727726Seed: try 2418Predicted password reset code: 807592The real code sent to the buyer: 807592The codes are different in the four languages, because the generators are different. The try is the same: 2418, because the seed is 03:40:17.
The idea
The cryptographic generator in every language
static final SecureRandom RANDOM = new SecureRandom();
static String resetToken() { byte[] bytes = new byte[32]; RANDOM.nextBytes(bytes); return Base64.getUrlEncoder().withoutPadding() .encodeToString(bytes);}
static String code() { return String.format("%06d", RANDOM.nextInt(1_000_000));}String first = resetToken();String second = resetToken();System.out.println("Token length: " + first.length());System.out.println("Only A–Z, a–z, 0–9, - and _: " + first.matches("[A-Za-z0-9_-]+"));System.out.println("Two tokens in a row differ: " + !first.equals(second));System.out.println("The code is 6 digits: " + code().matches("\\d{6}"));Token length: 43Only A–Z, a–z, 0–9, - and _: trueTwo tokens in a row differ: trueThe code is 6 digits: trueThe token is 32 bytes (256 bits), written in URL-safe Base64: 43 characters that can go into a link. Guessing it is as hard as guessing an AES-256 key.
SecureRandom is created once and reused: the first use gathers randomness from the system and can be slow. UUID.randomUUID() uses SecureRandom too.Try it
Predict the output
Two generators with the same seed. What will it print?
Random a = new Random(42);Random b = new Random(42);System.out.println(a.nextInt(100) == b.nextInt(100));System.out.println(a.nextInt(100) == b.nextInt(100));The same seed gives the same sequence, number by number. So the second numbers are equal too. This is exactly what the attacker relies on: his copy runs in step with the shop’s generator.
Try it
Fill in
A password reset token from the cryptographic generator:
static final SecureRandom RANDOM = new ();SecureRandom: Java’s cryptographic generator.Try it
Find the bug
The token is long and looks safe. Which line makes it predictable?
Pick the line with the bug.
new Random(System.currentTimeMillis()): the seed is the time in milliseconds. The attacker knows roughly when the reset was requested and tries a few thousand milliseconds. 32 bytes from a predictable generator are 32 predictable bytes.The idea
A comparison that gives nothing away
When the shop checks the code from the email, it compares it with the stored one. An ordinary string comparison stops at the first difference. The more leading characters are right, the longer it takes. Over the network the difference is nanoseconds, but with many tries and statistics it can be measured. That way the attacker guesses character by character: 10 tries for the first, 10 for the second…
The program below does not measure time: it counts how many characters the comparison looks at, which is what makes the time differ.
// What equals does inside: it stops at the first differencestatic int comparedChars(String secret, String guess) { int i = 0; while (i < secret.length() && secret.charAt(i) == guess.charAt(i)) { i++; } return Math.min(i + 1, secret.length());}String secret = "481937";for (String guess : List.of("000000", "400000", "480000", "481000")) { System.out.println(guess + ": characters compared " + comparedChars(secret, guess));}byte[] expected = secret.getBytes(StandardCharsets.UTF_8);for (String guess : List.of("481000", "481937")) { byte[] given = guess.getBytes(StandardCharsets.UTF_8); System.out.println(guess + ": MessageDigest.isEqual = " + MessageDigest.isEqual(expected, given));}000000: characters compared 1400000: characters compared 2480000: characters compared 3481000: characters compared 4481000: MessageDigest.isEqual = false481937: MessageDigest.isEqual = trueA constant-time comparison looks at every byte, whatever it finds, and the time does not depend on how much was guessed. Every language has it ready: MessageDigest.isEqual. Use it for everything secret that comes from outside: codes, tokens, JWT signatures, HMACs.
The idea
Encryption: a ready-made algorithm from a ready-made library
The shop keeps the IBAN of buyers who return goods. In the database it must be encrypted, and on reading it must be certain that nobody changed it. Both are done by authenticated encryption. The standard is AES-GCM: it encrypts and adds a 16-byte tag. If a single bit changes, the tag does not match and decryption stops with an error.
AES-GCM is in the standard library:static byte[] encrypt(SecretKey key, byte[] nonce, String text) throws Exception { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, nonce)); return cipher.doFinal(text.getBytes(StandardCharsets.UTF_8));}
static String decrypt(SecretKey key, byte[] nonce, byte[] box) throws Exception { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, nonce)); return new String(cipher.doFinal(box), StandardCharsets.UTF_8);}KeyGenerator generator = KeyGenerator.getInstance("AES");generator.init(256);SecretKey key = generator.generateKey(); // in the real shop: from the secret storebyte[] nonce = new byte[12];new SecureRandom().nextBytes(nonce); // a new one for every messagebyte[] box = encrypt(key, nonce, "BG80 BNBG 9661 1020 3456 78");System.out.println("Encrypted: " + box.length + " bytes");System.out.println("Decrypted: " + decrypt(key, nonce, box));box[5] ^= 1;try { decrypt(key, nonce, box);} catch (AEADBadTagException e) { System.out.println("One byte changed: the tag check failed, refusing");}Encrypted: 43 bytesDecrypted: BG80 BNBG 9661 1020 3456 78One byte changed: the tag check failed, refusingSee it
A scheme of your own, broken in a few lines
A colleague decides AES is "too complicated" and writes his own encryption: every byte XOR a byte of a secret word. It looks like noise. But XOR undoes itself, and every invoice starts with the same words:
static byte[] xor(byte[] data, byte[] key) { byte[] out = new byte[data.length]; for (int i = 0; i < data.length; i++) { out[i] = (byte) (data[i] ^ key[i % key.length]); } return out;}byte[] key = "SHOP2026".getBytes(StandardCharsets.UTF_8); // the shop’s "secret" keybyte[] invoice = xor("INVOICE 2026-0142, total 249.90 €".getBytes(StandardCharsets.UTF_8), key);byte[] iban = xor("BG80 BNBG 9661 1020 3456 78".getBytes(StandardCharsets.UTF_8), key);// the attacker: every invoice starts like thisbyte[] known = "INVOICE ".getBytes(StandardCharsets.UTF_8);byte[] found = xor(Arrays.copyOf(invoice, 8), Arrays.copyOf(known, 8));System.out.println("The key is: " + new String(found, StandardCharsets.UTF_8));System.out.println("Another message: " + new String(xor(iban, found), StandardCharsets.UTF_8));The key is: SHOP2026Another message: BG80 BNBG 9661 1020 3456 78The secret word comes out of the first 8 bytes of one invoice, and with it everything ever encrypted can be read. No maths and no billions of tries are needed, only a known start of the text.
That is the rule of this lesson: do not invent your own cryptography. Not your own algorithm, not your own scheme around a ready-made algorithm, not your own generator. The people who make cryptography publish their schemes and wait years for others to break them. You use their work through a library.
More precisely, if you are curious
Which for what.
- Passwords: a slow hash with a salt (bcrypt, argon2id), the lesson on passwords. Never encryption: a password must not be readable back.
- Signing a message with a shared key: HMAC-SHA256, as in JWT.
- Encrypting data: AES-GCM (or ChaCha20-Poly1305) with a key from a secret store.
- Tokens and codes: the cryptographic generator.
- A hash for the integrity of a file: SHA-256 or SHA-512, as in the lesson on dependencies. MD5 and SHA-1 are broken for this.
Higher-level libraries. AES-GCM still requires you never to repeat a nonce and to guard the key. Libraries like libsodium (NaCl), Google Tink and Fernet in Python’s cryptography hide these decisions too: you get encrypt(key, text) and decrypt(key, box), with no modes and no nonce.
Constant-time comparison inside. It compares every byte and collects the differences with XOR and OR: diff |= a[i] ^ b[i], and at the end checks diff == 0. There is no early exit. You do not write it yourself: the compiler may "optimise" it; the ready-made functions are built so that this cannot happen.
The 6-digit code. Even from a cryptographic generator, 6 digits are a million possibilities. So the code expires after minutes and locks after a few wrong tries. A token in a link is 32 bytes, and there these limits are not needed against guessing, only against leaks (in a log, for example).
UUID. UUID.randomUUID() in Java, uuid.uuid4() in Python and crypto.randomUUID() in Node come from a cryptographic generator. Guid.NewGuid() in .NET does too in practice, but the documentation does not promise it, so for secrets use RandomNumberGenerator. A UUID itself is 122 random bits: fine for an id, but a 32-byte token is clearer.
Exercise
Discount codes
Solve it in your own editor.
The shop wants 10-character discount codes that are easy to read: only capital Latin letters and digits, without 0, O, 1 and I, which get confused. The alphabet is ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (32 characters, 50 bits per code). Write discountCode with the cryptographic generator, make 1000 codes and check that all are from the alphabet and all are distinct:
Codes: 1000Each is 10 characters from the alphabet: trueDistinct: 1000More tasks
- Expiry. Store when each code was made and refuse codes older than 15 minutes.
- Tries. Lock the code after 5 wrong tries and count the tries.
- No modulo. Why is
ALPHABET[randomByte % 32]fine, butALPHABET[randomByte % 30]is not? (Hint: 256 divides by 32, but not by 30.)
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Lambdas · Passwords · JWT and its traps · Secrets in code and in git
- Tasks on this topic: Security for developers