crypto.randomUUID() returns a new random ID, 36 characters long. It is built into the browser, needs no library, and draws its bits from a cryptographically secure generator, so nobody can predict the next one.
const id = crypto.randomUUID(); // "3b241101-e2bb-4255-8caf-4136c566a962"
Press the button a few times. Each click is a fresh call.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>crypto.randomUUID()</title>
<style>
body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
button { padding: 9px 16px; font-size: 15px; border: 0; border-radius: 8px; background: #1d4ed8; color: #fff; cursor: pointer; }
#id { font: 600 17px/1.4 ui-monospace, Consolas, monospace; background: #fff; border: 1px solid #e1e4ea;
border-radius: 8px; padding: 12px; margin: 12px 0 6px; overflow-wrap: anywhere; }
.v { background: #d6f2df; color: #0f5132; border-radius: 3px; } /* version digit */
.r { background: #dbe7ff; color: #1d4ed8; border-radius: 3px; } /* variant digit */
.key { font-size: 13px; color: #374151; margin: 0 0 12px; }
ol { margin: 0; padding-left: 22px; font: 13px/1.6 ui-monospace, Consolas, monospace; color: #5b6270; overflow-wrap: anywhere; }
#ctx { font-size: 13px; margin-top: 10px; color: #374151; }
</style>
</head>
<body>
<button id="make">New UUID</button>
<div id="id">-</div>
<p class="key"><span class="v"> 4 </span> is always the version. <span class="r"> 8, 9, a or b </span> is the variant. The other 30 hex digits are random.</p>
<ol id="log"></ol>
<p id="ctx"></p>
<script>
const out = document.getElementById('id');
const log = document.getElementById('log');
const ctx = document.getElementById('ctx');
ctx.textContent = 'isSecureContext: ' + window.isSecureContext +
' | crypto.randomUUID is ' + typeof crypto.randomUUID;
document.getElementById('make').addEventListener('click', () => {
if (typeof crypto.randomUUID !== 'function') {
out.textContent = 'Not available: this page is not a secure context.';
return;
}
const id = crypto.randomUUID(); // e.g. "3b241101-e2bb-4255-8caf-4136c566a962"
// colour the version digit (position 14) and the variant digit (position 19)
out.innerHTML = id.slice(0, 14) + '<span class="v">' + id[14] + '</span>' +
id.slice(15, 19) + '<span class="r">' + id[19] + '</span>' + id.slice(20);
const li = document.createElement('li');
li.textContent = id;
log.prepend(li);
if (log.children.length > 5) log.lastElementChild.remove();
});
</script>
</body>
</html>
This guide is about IDs, tokens and passwords, where the numbers must be impossible to guess. For dice, shuffles and random integers in a range, see random numbers in JavaScript.
What is inside a UUID
A UUID is 128 bits written as 32 hexadecimal digits in five groups: 8, 4, 4, 4 and 12. With four hyphens, that is 36 characters. randomUUID() always returns lowercase letters.

Two digits are not random. The first digit of the third group is always 4, meaning version 4, "made from random numbers". The first digit of the fourth group is 8, 9, a or b, the variant.
The format is defined in RFC 9562, which replaced RFC 4122.
That leaves 122 random bits. You would need about 2.7 x 10^18 UUIDs before the chance of a single repeat reaches one in two, so you can treat each one as unique without checking.
Math.random() versus the crypto object
Math.random() gives an evenly spread number between 0 and 1, and that is all it promises. It is not cryptographically secure: its output comes from a fast formula over hidden state, and it was never meant to resist someone trying to predict the next value.

The browser's crypto object gives you two tools backed by a secure generator:
Math.random() |
crypto.getRandomValues() |
crypto.randomUUID() |
|
|---|---|---|---|
| Returns | One number, 0 to under 1 | The typed array you passed, filled | A 36-character string |
| Secure against guessing | No | Yes | Yes |
| Needs a secure context | No | No | Yes |
| Use it for | Games, shuffles, colours | Short IDs, passwords, tokens | Unique IDs for records, files, keys |
A useful rule: if someone would gain anything by guessing the value, such as an invite code, a reset link or a password, use the crypto object.
The secure-context requirement
randomUUID() only exists in a secure context. On other pages, crypto.randomUUID is undefined, and calling it throws "crypto.randomUUID is not a function".

We tested this in Chromium, Firefox and WebKit, and all three behaved the same way:
- Secure:
https://pages,http://localhost,http://127.0.0.1, and files opened from disk with afile://address. - Not secure: plain
http://with a network address, such as a phone opening your laptop's dev server at192.168.0.12:8080. A new blank tab (about:blank) also reported false. - Both:
crypto.getRandomValues()was available everywhere.crypto.subtlefollowed the same rule asrandomUUID().
Check before you call, or read window.isSecureContext:
if (typeof crypto.randomUUID === 'function') { /* safe to call */ }
The HTTPS guide explains why browsers keep some features behind it.
A UUID without randomUUID()
When a page may be served over plain http, build the same kind of UUID from 16 secure bytes. Set the version and variant bits by hand, then write the bytes as hex:
function uuidv4() {
const b = crypto.getRandomValues(new Uint8Array(16));
b[6] = (b[6] & 0x0f) | 0x40; // version 4
b[8] = (b[8] & 0x3f) | 0x80; // variant: first two bits 10
const h = [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
return h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) +
'-' + h.slice(16, 20) + '-' + h.slice(20);
}
const newId = typeof crypto.randomUUID === 'function'
? () => crypto.randomUUID()
: uuidv4;
The output has the same format and the same 122 random bits as randomUUID().
Short IDs with getRandomValues()
A UUID is long for a link or a code someone types. For a shorter ID, pick characters from an alphabet using random bytes. The trap is how you map a byte (0 to 255) to a character.
With 64 characters, byte & 63 is exact, because 256 is 4 x 64.
With 62 characters (letters and digits only), byte % 62 is not: 256 is 4 x 62 + 8, so the first 8 characters each get one extra byte. They come up 5 times in 256 instead of 4, which is 25% more often.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Short random IDs with getRandomValues</title>
<style>
body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
.row { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; margin-bottom: 10px; font-size: 14px; }
select { padding: 6px; font-size: 14px; max-width: 100%; }
button { padding: 8px 14px; font-size: 14px; border: 0; border-radius: 8px; background: #1d4ed8; color: #fff; cursor: pointer; }
#id { font: 600 20px ui-monospace, Consolas, monospace; background: #fff; border: 1px solid #e1e4ea;
border-radius: 8px; padding: 10px 12px; overflow-wrap: anywhere; }
#bits { font-size: 13px; color: #374151; margin: 6px 0 12px; }
#chart { display: flex; align-items: flex-end; gap: 1px; height: 170px; padding: 6px 4px 0;
background: #fff; border: 1px solid #e1e4ea; border-radius: 8px; }
.bar { flex: 1; min-width: 0; background: #16a34a; border-radius: 2px 2px 0 0; }
.bar.hot { background: #ea580c; }
#note { font-size: 13px; color: #374151; min-height: 54px; }
</style>
</head>
<body>
<div class="row">
<select id="way">
<option value="mask">64 characters, byte & 63 (even)</option>
<option value="mod">62 characters, byte % 62 (biased)</option>
<option value="reject">62 characters, skip bytes 248-255 (even)</option>
</select>
<label>Length <select id="len"><option>8</option><option selected>12</option><option>16</option><option>21</option></select></label>
</div>
<div class="row"><button id="make">New ID</button></div>
<div id="id">-</div>
<p id="bits"></p>
<div id="chart"></div>
<p id="note"></p>
<script>
const $ = (id) => document.getElementById(id);
const A62 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const A64 = A62 + '-_';
// turn one random byte (0-255) into a character index, or -1 to skip it
const pick = {
mask: (b) => b & 63, // 256 is a multiple of 64: no bias
mod: (b) => b % 62, // 256 = 4 x 62 + 8: indexes 0-7 get one extra byte
reject: (b) => (b < 248 ? b % 62 : -1), // 248 = 4 x 62: throw the last 8 away
};
function shortId(len, way) {
const abc = way === 'mask' ? A64 : A62;
let id = '';
while (id.length < len) {
const bytes = crypto.getRandomValues(new Uint8Array(Math.min(len * 2, 65536)));
for (const b of bytes) {
const i = pick[way](b);
if (i >= 0 && id.length < len) id += abc[i];
}
}
return id;
}
function draw() {
const way = $('way').value, len = +$('len').value;
const abc = way === 'mask' ? A64 : A62;
$('id').textContent = shortId(len, way);
$('bits').textContent = len + ' characters from ' + abc.length + ' = ' +
(len * Math.log2(abc.length)).toFixed(1) + ' bits (a v4 UUID has 122)';
// count 256,000 characters to see whether each one is equally likely
const counts = new Array(abc.length).fill(0);
for (const ch of shortId(256000, way)) counts[abc.indexOf(ch)]++;
const max = Math.max(...counts);
$('chart').innerHTML = counts.map((c, i) =>
'<div class="bar' + (way === 'mod' && i < 8 ? ' hot' : '') +
'" style="height:' + (c / max * 100) + '%"></div>').join('');
const first8 = counts.slice(0, 8).reduce((a, b) => a + b) / 8;
const rest = counts.slice(8).reduce((a, b) => a + b) / (abc.length - 8);
$('note').textContent = 'Out of 256,000 characters, A-H came up ' + Math.round(first8) +
' times each on average and the others ' + Math.round(rest) + '. Ratio ' +
(first8 / rest).toFixed(2) + (way === 'mod' ? ': A-H are too common.' : ': even.');
}
$('make').addEventListener('click', draw);
$('way').addEventListener('change', draw);
$('len').addEventListener('change', draw);
draw();
</script>
</body>
</html>
The fix for 62 characters is to throw away bytes 248 to 255 and draw again. Length matters too, because each character adds bits:
| ID | Random bits | IDs before a 50% chance of any repeat |
|---|---|---|
| 8 characters from 64 | 48 | about 2.0 x 10^7 |
| 12 characters from 64 | 72 | about 8.1 x 10^10 |
| Version 4 UUID | 122 | about 2.7 x 10^18 |
For an invite code or a share link that has to stay private, use at least 12 characters from 64. For a public label that only needs to look tidy, 8 is plenty.
A finished example: a password generator
A password is a short ID with a bigger alphabet. This generator uses getRandomValues() with a Uint32Array and the same skip rule, so every character in the chosen set is equally likely.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Password generator</title>
<style>
body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
.card { background: #fff; border: 1px solid #e1e4ea; border-radius: 12px; padding: 14px; max-width: 460px; }
#pw { width: 100%; box-sizing: border-box; font: 600 18px ui-monospace, Consolas, monospace; padding: 10px;
border: 1px solid #cfd4dc; border-radius: 8px; background: #fafbfc; }
.row { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; margin-top: 12px; font-size: 14px; }
input[type=range] { flex: 1; min-width: 140px; }
button { padding: 9px 16px; font-size: 15px; border: 0; border-radius: 8px; background: #1d4ed8; color: #fff; cursor: pointer; }
#meter { height: 8px; border-radius: 99px; background: #e5e7eb; margin-top: 12px; overflow: hidden; }
#fill { height: 100%; width: 0; background: #16a34a; }
#info { font-size: 13px; color: #374151; margin: 6px 0 0; }
</style>
</head>
<body>
<div class="card">
<input id="pw" readonly aria-label="Generated password">
<div class="row">
<label for="len">Length <b id="lenOut">16</b></label>
<input type="range" id="len" min="6" max="40" value="16">
</div>
<div class="row">
<label><input type="checkbox" id="lower" checked> a-z</label>
<label><input type="checkbox" id="upper" checked> A-Z</label>
<label><input type="checkbox" id="digit" checked> 0-9</label>
<label><input type="checkbox" id="symbol"> symbols</label>
</div>
<div class="row"><button id="make">Generate</button></div>
<div id="meter"><div id="fill"></div></div>
<p id="info"></p>
</div>
<script>
const $ = (id) => document.getElementById(id);
const SETS = {
lower: 'abcdefghijklmnopqrstuvwxyz',
upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
digit: '0123456789',
symbol: '!@#$%&*-_=+?',
};
// secure, unbiased whole number from 0 to n - 1
function secureInt(n) {
const limit = Math.floor(2 ** 32 / n) * n; // largest multiple of n that fits
const buf = new Uint32Array(1);
do crypto.getRandomValues(buf); while (buf[0] >= limit);
return buf[0] % n;
}
function generate() {
const len = +$('len').value;
$('lenOut').textContent = len;
const chars = Object.keys(SETS).filter((k) => $(k).checked).map((k) => SETS[k]).join('');
if (!chars) { $('pw').value = ''; $('info').textContent = 'Tick at least one box.'; return; }
let pw = '';
for (let i = 0; i < len; i++) pw += chars[secureInt(chars.length)];
$('pw').value = pw;
const bits = len * Math.log2(chars.length); // strength when the attacker knows the rules
$('fill').style.width = Math.min(100, bits / 128 * 100) + '%';
$('info').textContent = len + ' characters from a set of ' + chars.length +
' = ' + Math.round(bits) + ' bits of randomness.';
}
$('make').addEventListener('click', generate);
document.querySelectorAll('.card input:not(#pw)').forEach((el) => el.addEventListener('input', generate));
$('pw').addEventListener('focus', () => $('pw').select()); // one tap selects it for copying
generate();
</script>
</body>
</html>
The helper that picks one character:
function secureInt(n) { // 0 to n - 1
const limit = Math.floor(2 ** 32 / n) * n; // largest multiple of n
const buf = new Uint32Array(1);
do crypto.getRandomValues(buf); while (buf[0] >= limit);
return buf[0] % n;
}
Strength is length times Math.log2(setSize). Sixteen characters from 62 letters and digits give about 95 bits. Adding symbols helps less than adding characters: each extra character adds almost 6 bits.
- One call, many values:
getRandomValues()fills up to 65,536 bytes in a call. Asking for more throwsQuotaExceededError. - Integer arrays only: a
Float64ArraythrowsTypeMismatchError. UseUint8ArrayorUint32Array. - Copying: the demo selects the text on tap. A copy button goes one step further.
When it does not work
| What you see | Cause | Fix |
|---|---|---|
| "crypto.randomUUID is not a function" | The page is plain http on a network address, not a secure context | Open it via localhost, serve it over https, or use the uuidv4() fallback |
| Works on your laptop, fails on your phone | The laptop uses localhost; the phone uses your network address | Same fix: https, or the fallback |
QuotaExceededError |
More than 65,536 bytes in one call | Fill the array in pieces |
TypeMismatchError |
A float array or a plain array was passed | Pass a Uint8Array or Uint32Array |
| Some characters show up more often | byte % n where 256 is not a multiple of n |
Use 64 characters, or skip the last incomplete block |
| An ID stored elsewhere does not match | The other system saved it in uppercase | Compare with toLowerCase() on both sides |
Share it as a link
An ID or password generator is easier to hand over as a page than as code. Sent as a link, the page runs, and the person can press the button themselves. A local file only works on the computer that has it.
Paste the page into a NOS document and choose Create share link. HTML to link walks through it.
The shared page is served over https, so crypto.randomUUID() is available and its scripts run as written. If you change the code later, the same link shows the new version.