~/blog

crypto.randomUUID is undefined on your LAN dev server

published

#javascript#browser#security

TL;DR

crypto.randomUUID() is restricted to secure contexts. http://localhost counts as one; http://192.168.1.20 does not. So the call works on your machine and throws TypeError: crypto.randomUUID is not a function the moment you open the same dev server on a phone over the LAN. Either serve the dev server over HTTPS, or fall back to crypto.getRandomValues(), which is the one Crypto member that works in an insecure context.

The problem

You test on http://localhost:5173, everything is fine. You read the LAN URL off the dev server banner, open it on a phone, and the page dies:

Uncaught TypeError: crypto.randomUUID is not a function

Same browser build, same code, same server process. The only thing that changed is the origin in the address bar.

Why it happens

Crypto.randomUUID() is a secure-context-only API. MDN states the restriction directly:

This feature is available only in secure contexts (HTTPS), in some or all supporting browsers.

“Secure context” is not the same as “HTTPS”. The spec treats a set of origins as potentially trustworthy because they cannot be tampered with in transit, and MDN spells out which locally-delivered ones qualify:

Locally-delivered resources such as those with http://127.0.0.1, http://localhost, and http://*.localhost URLs (for example, http://dev.whatever.localhost/) are not delivered using HTTPS, but they can be considered to have been delivered securely because they are on the same device as the browser.

Private LAN ranges are not on that list. 192.168.x.x, 10.x.x.x and 172.16–31.x.x over plain http are ordinary insecure origins — the traffic really does cross a network someone else could be on.

OriginSecure contextcrypto.randomUUID()
https://example.comyesworks
http://localhost:5173yesworks
http://127.0.0.1:5173yesworks
http://dev.app.localhost:5173yesworks
http://192.168.1.20:5173noundefined
file:///…/index.htmlyesworks

This is also why the failure is invisible in CI and in unit tests: jsdom and Node both run the code somewhere that satisfies the check, and your own browser tab is on localhost. The only reproducer is a second device.

What to do

Pick one. The first is better if you test on devices often; the second is better if you just want the call not to explode.

Option 1 — serve the dev server over HTTPS

Every modern dev server can do this, and the origin becomes secure for every Web API at once (randomUUID, crypto.subtle, service workers, getUserMedia). With Vite:

npm install --save-dev vite-plugin-mkcert
// vite.config.ts
import { defineConfig } from 'vite'
import mkcert from 'vite-plugin-mkcert'

export default defineConfig({
  plugins: [mkcert()],
  server: { host: true }, // listen on the LAN interface too
})

The phone will still warn about the certificate unless you install the local CA on it. That is the actual cost of this option, and it is why the fallback below exists.

Option 2 — fall back to getRandomValues

Crypto.getRandomValues() has no secure-context requirement. MDN is explicit about it being the exception:

getRandomValues() is the only member of the Crypto interface which can be used from an insecure context.

So you can generate the same v4 UUID by hand. The layout comes from RFC 9562 §5.4: 16 random bytes, then force the version nibble to 4 in byte 6 and the two top variant bits to 10 in byte 8.

export function uuidv4(): string {
  // Native path: secure context, and it is the better RNG plumbing.
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
    return crypto.randomUUID()
  }

  const bytes = new Uint8Array(16)
  crypto.getRandomValues(bytes)

  bytes[6] = (bytes[6] & 0x0f) | 0x40 // version 4
  bytes[8] = (bytes[8] & 0x3f) | 0x80 // variant 10xx

  const hex: string[] = []
  for (const b of bytes) hex.push(b.toString(16).padStart(2, '0'))

  return (
    hex.slice(0, 4).join('') +
    '-' +
    hex.slice(4, 6).join('') +
    '-' +
    hex.slice(6, 8).join('') +
    '-' +
    hex.slice(8, 10).join('') +
    '-' +
    hex.slice(10, 16).join('')
  )
}

Both branches produce a 36-character v4 UUID from a cryptographically secure source. The randomness is equivalent; only the convenience method is gated.

One quota to know about: getRandomValues() throws QuotaExceededError if the typed array is longer than 65,536 bytes, so batch-generating UUIDs means chunking at 4,096 per call. It also rejects float arrays — Uint8Array and the other integer types only.

Caveats

References