GUIDESEPISODES
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.

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
A free audit can take a second look at the rest of your code.
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.

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”.

See it for yourself in ten seconds
Open your browser console on any page and run this. Nothing is sent anywhere.
// 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:

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 for | Fine with Math.random()? | What to use instead |
|---|---|---|
| An animation delay, a shuffled list, a demo id | Yes | Nothing to change |
| A login or one-time code | No | crypto.randomInt (Node) or getRandomValues() |
| A password-reset or verification link | No | crypto.randomUUID() |
| An invite or share link | No | crypto.randomUUID() |
| A session id or an API key you hand out | No | crypto.randomBytes(32).toString('hex') |
| An uploaded file’s name | No | crypto.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:

Open the full chapterIncludes code, a note
// 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:
// 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:
// 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.

Open the full chapterIncludes steps, code, a note
- It expires. Ten minutes is plenty for a code someone is reading off their phone.
- It has a try limit. Five wrong guesses and that code is finished — not the account, the code.
- It is used once. After a successful sign-in, mark it used.
- 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:
-- 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:
// 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.

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.

Open the full chapterIncludes steps, a note
- 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.
- Swap the source.
crypto.randomUUID()for links,crypto.randomIntorgetRandomValues()for digits. NeverMath.random(). - Expire them. Ten minutes, five tries, one use, stored as a hash.
THE QUICK READ
What to take away.
In one minute.
- 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.” - 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.
- 03
The fix is usually one line:
crypto.randomUUID()for links,crypto.randomInt(Node) orcrypto.getRandomValues()(browser and edge) for digits. - 04
Then make codes die: ten minutes, five tries, one use, stored as a hash — so guessing is pointless even if someone tries.
- 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.
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
- MDN: Math.random() — “does not provide cryptographically secure random numbers”
- OWASP: Insecure Randomness
- CWE-330: Use of Insufficiently Random Values (MITRE)
- MDN: Crypto.randomUUID()
- Node.js Docs: crypto (randomUUID, randomInt, randomBytes)
- MDN: Crypto.getRandomValues()
- Security Weaknesses of Copilot-Generated Code in GitHub Projects (ACM TOSEM / arXiv 2310.02059)
