FREE1 free audit every month. No card, every finding included.

GUIDESEPISODES

EP 08 · GUESSAster × Copilot

Your login codes are guessable. The three-step fix.

Your AI wrote the code that makes your login codes. It used Math.random(), because the word says random. Random is not the same as secret — and the people who collect codes know the difference. Here is the whole fix, in plain words.

  • 10 min read
  • Beginner
  • Oct 5, 2026
Voxel episode thumbnail tagged EP. 08 on a dark green background with faint code: the headline reads YOUR LOGIN CODE in cream and IS GUESSABLE in lime; in a garage turned into a game-show stage under a lime neon GUESS MY CODE sign, an alarmed Aster stands at a slim wooden lectern with his laptop at his side and the lime lock on his black shirt, Copilot hovers beside him as the host, and a big carnival prize wheel between them has stopped with the pointer on 67.

COMMENTED GUESS? HERE’S THE FIX.

The fix, in three moves.

Tick each one off as you do it. Your progress stays on this device.

0 of 3 done

01THE STORY

A stranger typed the code that was emailed to you

Why it mattersYour AI picked the function whose name matched the word you used. Nobody told it the number had to be a secret.

You built a sign-in that emails a six-digit code. It worked. Then somebody who is not you opened an account that is not theirs, and the only thing they typed was a number.

Illustration: in a dark green garage studio with studio lamps and flight cases, voxel Aster stands at a slim wooden lectern holding up his phone, alarmed, his closed silver laptop at his side and the lime padlock logo visible on his black shirt; the phone screen shows six large black digits, 067419; Copilot, a floating violet-blue voxel helmet with a dark visor, glowing cyan scanline eyes and goggles pushed up on its forehead, hovers beside him with one blue cube mitt raised.
The code your app emailed. Six digits, and nobody ever asked where they came from.
Open the full chapterIncludes a note, code

Nothing was hacked. Your app handed out codes that can be worked out, and somebody worked them out.

Why “random” is not “secret”

Math.random() gives you a number that looks different every time. That is all it promises. It is fast, it is fine for shuffling a list or picking a colour, and the browser makers say so themselves:

The numbers come out of a formula with a starting point. Watch enough of them and the next ones stop being a surprise. OWASP puts it in one line: “Standard pseudo-random number generators cannot withstand cryptographic attacks.” It even has its own number in the industry catalogue of weaknesses: CWE-330, “Use of Insufficiently Random Values”.

Illustration: a big voxel carnival prize wheel on a wooden stand in the middle of the game-show garage, cream and teal wedges with black numbers and a red pointer at the top resting on a wedge that reads 67; two faint ghost copies of the same wheel behind it have also stopped on 67; Copilot hovers to the right looking smug, voxel Aster stands at the left unimpressed with his laptop at his side.
Same wheel, same spin, same number. A code generator that can be predicted is a wheel with one wedge.

See it for yourself in ten seconds

Open your browser console on any page and run this. Nothing is sent anywhere.

browser console
// paste this in your browser console and look at the numbers
for (let i = 0; i < 5; i++) console.log(Math.floor(Math.random() * 900000) + 100000);

Five codes, instantly. That is how fast anybody can make them. The problem is not that you can see five — it is that the sequence they come from is a formula, not a secret.

02WHERE IT HIDES

Four places your app makes something unguessable

Why it mattersOne of these usually survives the clean-up, because nobody thinks of it as a code.

It is almost never only the login code. Ask your AI about each of these by name:

Illustration: a dark game-show answer board on a wooden easel in the garage studio with four lime-outlined cards in a row, each with a white pictogram and a word: RESET with an envelope, INVITE with a ticket, SHARE with a link chain, LOGIN with a padlock; Copilot hovers in front pointing a cube mitt at the first card while voxel Aster stands beside the board reading them, his closed laptop at his side.
Four cards, one question each: what makes this one impossible to guess?
Open the full chapterIncludes a table, a note
  • The password-reset link. The part after the ? is the whole password.
  • The email-verification link. Guess it and you confirm an address you do not own.
  • Invite codes. One guess and a stranger is inside your workspace.
  • “Anyone with the link can view.” That link is the only lock on the file.

And the quieter ones: session identifiers, API keys your app generates for customers, upload file names (/uploads/4821.pdf is a shelf anyone can walk along), and coupon codes that cost real money.

What it is forFine with Math.random()?What to use instead
An animation delay, a shuffled list, a demo idYesNothing to change
A login or one-time codeNocrypto.randomInt (Node) or getRandomValues()
A password-reset or verification linkNocrypto.randomUUID()
An invite or share linkNocrypto.randomUUID()
A session id or an API key you hand outNocrypto.randomBytes(32).toString('hex')
An uploaded file’s nameNocrypto.randomUUID() plus a permission check

03THE FIX

One line, in the place that makes the code

Why it mattersThe fix is small, so it never gets a ticket of its own. Do it while you are reading this.

This is what your AI wrote:

Illustration: a large floating dark code card with a thick lime border in the middle of the garage studio; the top line reads Math.random() struck through with a thick red line, and below it crypto.randomUUID() glows lime with a tick beside it; voxel Aster stands at the left with an open hand toward the card, relieved, his closed silver laptop at his side; Copilot hovers at the right with a cube mitt raised.
The whole repair, on one card: stop asking the maths function for a secret.
Open the full chapterIncludes code, a note
✕ Don’tsend-login-code.js
// send-login-code.js  — what the AI wrote
const code = Math.floor(Math.random() * 900000) + 100000;   // 6 digits
await sendEmail(user.email, `Your code is ${code}`);

It reads perfectly. It is also the exact pattern a study of Copilot-written code found more than any other weakness.

And this is the same thing, done with the random source that is meant for secrets:

✓ Dosend-login-code.js
// send-login-code.js  — Node (and anywhere `node:crypto` runs)
import crypto from 'node:crypto';

// a 6-digit code, from the crypto random source
const code = String(crypto.randomInt(0, 1000000)).padStart(6, '0');

// a link people click instead of typing: longer and unguessable
const token = crypto.randomUUID();

crypto.randomInt and crypto.randomUUID are built into Node — nothing to install. randomInt also avoids the small bias you get from a plain % on a raw number.

In the browser, on Deno, or on an edge runtime (Cloudflare Workers, Vercel Edge), the same two tools are already on crypto:

✓ Doanywhere Web Crypto runs
// browser, Deno, Cloudflare Workers, Vercel Edge: the Web Crypto API
const token = crypto.randomUUID();                 // for links

const n = new Uint32Array(1);                      // for a 6-digit code
crypto.getRandomValues(n);
const code = String(n[0] % 1000000).padStart(6, '0');

crypto.randomUUID() needs a secure context (https or localhost). If your framework runs this file on the server, prefer the Node version above.

04THE LIMITS

Ten minutes, five tries, one use

Why it mattersMost real break-ins are not clever. They are patient.

Unguessable is half of it. The other half is that a code should not be waiting around to be guessed.

Illustration: a big voxel kitchen timer with a glowing lime dial showing 10:00 stands on the wooden lectern in the garage studio; above it a small white paper code slip is crumbling into grey dust; Copilot hovers beside it pointing a cube mitt at the timer, and voxel Aster leans on the lectern watching, his closed silver laptop at his side.
A code with an expiry and a try limit is a code nobody can grind at.
Open the full chapterIncludes steps, code, a note
  1. It expires. Ten minutes is plenty for a code someone is reading off their phone.
  2. It has a try limit. Five wrong guesses and that code is finished — not the account, the code.
  3. It is used once. After a successful sign-in, mark it used.
  4. It is stored as a hash. Anyone who sees your database table sees nothing useful.

In a Postgres or Supabase app the table is this small:

migration
-- one row per code: who it is for, when it dies, how many tries are left
create table login_codes (
  id          uuid primary key default gen_random_uuid(),
  user_id     uuid not null,
  code_hash   text not null,                       -- store the hash, not the code
  expires_at  timestamptz not null default now() + interval '10 minutes',
  tries       int  not null default 0,
  used_at     timestamptz
);

Store code_hash, never the code. gen_random_uuid() is built into Postgres 13 and later (and into Supabase).

And the check reads the same way for every failure, so nobody can learn anything from the wording:

✓ Doverify-code.js
// checking a code: expired, used up, or wrong — all answer the same way
const row = await db.loginCodes.findFirst({ where: { userId } });

if (!row || row.usedAt || row.expiresAt < new Date() || row.tries >= 5) {
  return { ok: false, message: 'That code is not valid.' };
}
await db.loginCodes.update({ where: { id: row.id }, data: { tries: row.tries + 1 } });

Expired, used, out of tries, or simply wrong — one answer. Add a per-account limit on how many codes can be requested an hour, too.

05THE RECEIPT

How somebody actually collects your codes

Why it mattersIt is worth seeing the attack as boring. That is why it works.

There is no hacking in this story. Somebody signs up, asks for a code, writes it down, asks again, writes that one down too. Two hundred codes later the pattern shows up — because a formula always shows up.

Illustration: three teal folding chairs at the side of the dark garage stage; on the end chair a faceless hooded dark grey voxel figure sits forward under a single warm spotlight holding up a long printed list headed HIS LIST with seven rows of six-digit numbers, each ending in 067; voxel Aster stands in the foreground at the edge of the light looking back, shocked, his laptop at his side.
Nobody breaks in. They ask your app for codes, write them down, and wait.
Open the full chapterIncludes a note

Then they ask for a code to your email address, and they do not need to read it.

What this looks like in your logs

  • A single account requesting codes over and over, minutes apart.
  • Lots of failed code checks, then one that works.
  • A sign-in from a device that has never been seen, right after a reset link.

06BEFORE YOU SHIP

The three steps, every launch

Why it mattersThe check takes two minutes. The clean-up after a stranger walks in does not.

Then paste the prompt at the top of this page into your agent before every launch. New code arrives with every feature, and Math.random() is still the first thing an AI reaches for.

Illustration: a wooden easel in the garage studio holds a dark board with three big lime numerals down the left and a white pictogram beside each: 1 with a magnifying glass, 2 with a key and shield, 3 with a clock; Copilot hovers at the right of the board with a cube mitt raised at step one, and voxel Aster gives a thumbs-up at the left with his closed silver laptop at his side, the lime lock on his shirt visible.
Three steps. Ask, swap, expire — then do it again before the next launch.
Open the full chapterIncludes steps, a note
  1. Find them. “Where does my app make codes, tokens or links, and what makes them random?” Your AI can list every one in a minute.
  2. Swap the source. crypto.randomUUID() for links, crypto.randomInt or getRandomValues() for digits. Never Math.random().
  3. Expire them. Ten minutes, five tries, one use, stored as a hash.

THE QUICK READ

What to take away.
In one minute.

  1. 01

    Math.random() was never meant to be secret. MDN says it plainly: it “does not provide cryptographically secure random numbers. Do not use them for anything related to security.”

  2. 02

    Anything a stranger could hold and walk in with — a login code, a reset link, an invite, a share URL — has to come from the crypto random source instead.

  3. 03

    The fix is usually one line: crypto.randomUUID() for links, crypto.randomInt (Node) or crypto.getRandomValues() (browser and edge) for digits.

  4. 04

    Then make codes die: ten minutes, five tries, one use, stored as a hash — so guessing is pointless even if someone tries.

  5. 05

    In a study of 733 Copilot-written files, weak random values were the most common security weakness of all (18% of everything found).

FOR YOUR CODING AGENT

Let your agent check
your own code.

Paste this into Claude Code, Cursor, Codex, Gemini CLI or Copilot. It only reads and reports; it won’t change your code until you approve.

agent-prompt.md
You are a security reviewer for this codebase. Investigate ONE issue: values that are supposed to be unguessable but are made with a non-cryptographic random source.

Where to look:
- Every call to `Math.random()`, `Date.now()`, `new Date().getTime()`, incrementing counters, `uuid` helpers written by hand, and short `substring`/`slice`/`toString(36)` tricks.
- The things they are used for: login or one-time codes, password-reset tokens and links, email-verification links, invite codes, share or "secret" URLs, session identifiers, API keys, file names for uploads, and coupon codes.

How to judge each one:
1. If the value only decides something cosmetic (an animation delay, a shuffled list, a demo id), it is fine. Say so and move on.
2. If a stranger who holds the value gets access to data or an account, it must come from a cryptographic source: `crypto.randomUUID()`, `crypto.getRandomValues()`, or Node's `crypto.randomInt` / `crypto.randomBytes`. Anything else is a finding.
3. Also flag codes that never expire, codes with no limit on how many times they can be tried, and codes stored in the database in plain text.

Report a table: file:line | what the value is used for | what's wrong | severity (critical, high, medium, low) | the minimal fix (code).

Rules: read and report only. Don't modify any file until I approve. Never print secrets, tokens or real codes: mask them.

Then ask me which fixes to apply.

QUESTIONS PEOPLE ASK

Good questions.
Short answers.

Is Math.random() always wrong?

No. It is fine for anything cosmetic: shuffling a list, picking a colour, an animation delay, a demo id. It is only wrong when the value is supposed to be a secret — a code, a token, a link that lets someone in. MDN’s own note is the rule of thumb: do not use it for anything related to security.

How does anyone guess a random number?

They do not guess it one by one. A pseudo-random generator produces its numbers from a formula with a hidden starting point, so the numbers are a sequence rather than surprises. Collect enough of them — by asking your app for codes — and the rest of the sequence stops being secret. That is why OWASP says standard pseudo-random generators cannot withstand cryptographic attacks.

What is the difference between crypto.randomUUID() and getRandomValues()?

crypto.randomUUID() hands you a finished unguessable string (122 random bits) — perfect for a link or a token. crypto.getRandomValues() fills an array with random bytes, which is what you want when you need digits, a specific length, or your own alphabet. In Node, crypto.randomInt and crypto.randomBytes do the same job.

My app runs on Supabase or Firebase. Does that change anything?

The generator still lives in your code, so the fix is the same. In Postgres (and Supabase) gen_random_uuid() is a good source for ids created in the database. Remember that a token is only a lock if the rules behind it also check who is asking.

Is a six-digit code enough?

Only with limits. A million possibilities falls fast to a script that can try a thousand a minute, so a six-digit code needs an expiry, a try limit and one use. If people do not have to type it, send a link with a randomUUID() token instead.

Does Aster’s audit flag this?

Not today. Aster’s scan reads your repository for exposed secrets, Supabase and Firebase rules, and out-of-date packages with known holes. Weak randomness is not on that list — that is why this guide and the agent prompt above exist. Your first repository audit is free either way.

Why does AI-written code do this so often?

Because Math.random() is the most common way to get a random number in JavaScript, and the model writes what it has seen most. A study of 733 Copilot-written files found security weaknesses in 27% of them, and weak random values were the single most common kind, 18% of everything found.

How do I know the fix worked?

Ask your agent to re-run the review prompt and show you the table again: every code, token and link should now name a crypto source. Then request a code yourself, wait eleven minutes and try it — it should be refused.

Random is not the same as secret. Swap the one line, give your codes an expiry and a try limit, and the wheel stops landing on the same number. If you want to know what else your AI left in the repository, your first Aster audit is free.
Source notebook7 links
  1. MDN: Math.random() — “does not provide cryptographically secure random numbers”
  2. OWASP: Insecure Randomness
  3. CWE-330: Use of Insufficiently Random Values (MITRE)
  4. MDN: Crypto.randomUUID()
  5. Node.js Docs: crypto (randomUUID, randomInt, randomBytes)
  6. MDN: Crypto.getRandomValues()
  7. Security Weaknesses of Copilot-Generated Code in GitHub Projects (ACM TOSEM / arXiv 2310.02059)

Done with the guide?
Let Aster check the rest.

1 free audit every month. Aster reads your code, env files, database rules and packages. You get each finding in plain English, with the file and line.

Aster

CHECKING

YOUR GUIDE TO ASTER

Hi, I’m Aster.
Ask me what Aster checks.

I can’t see your code from here. Please don’t paste secrets or private code.

Prepared answers are always available.