There is no UUID in the Kotlin standard library, and the fixes differ per platform
Random is seeded differently per target and is not secure anywhere. An id generator is a security decision.
TL;DR
java.util.UUID is not available in shared code. The two-minute workaround is a Random-based
hex string, and it is wrong in a way that only matters later: kotlin.random.Random is not a
CSPRNG on any target, its default seeding differs per platform, and an id built from it is
guessable.
Whether that matters depends on what the id is. A local list key can be anything unique. A share link, an invite token, or an idempotency key cannot be guessable — and those get written with the same helper, because it is the one that exists.
Separate the two. A sortable local id and an unguessable token are different functions with different sources, and conflating them is how a share link becomes enumerable.
The case this post is about
Three things needing identifiers in one feature:
- A local id for an optimistic list insert, before the server assigns one.
- An idempotency key so a retried request does not double-charge.
- A share token in a public URL.
One helper, because it is the one someone wrote first:
// commonMain
object Ids {
fun new(): String = (1..32)
.map { "0123456789abcdef".random() }
.joinToString("")
}
val localId = Ids.new() // fine
val idempotencyKey = Ids.new() // probably fine
val shareUrl = "https://app.example/s/${Ids.new()}" // not fine
Every call site reads identically. That is the problem — nothing about Ids.new() says what
guarantees it provides.
The symptom
Three findings, in increasing severity.
Duplicate ids on wasm after a reload. Two optimistic inserts in a fresh page produced the same local id, which should be essentially impossible for 128 bits of randomness:
page load 1: a3f1c9e4... page load 2 (fresh): a3f1c9e4...
String.random() uses Random.Default, whose seeding is platform-dependent. On the target and
version we were on, a fresh wasm instance produced a reproducible sequence — which is exactly the
behaviour a non-cryptographic PRNG is permitted to have.
Share links were enumerable. With a predictable generator, an attacker who collects a few tokens can derive the sequence. We did not need to demonstrate the full attack to act on it — a 32-hex-character token from a non-CSPRNG is not a secret, by the generator's own documentation.
And the ids were useless as database keys. Fully random ids have no ordering, so an index on them has poor locality and an insert lands in a random page. For a local cache this cost real scroll performance:
10,000 rows, insert + sort by id
random hex ids index churn, 340ms for a page query
time-sortable 12ms
Why the obvious fix failed
"Use Random.nextBytes instead of String.random()." Same generator underneath. The symptom
was never the string construction.
"Seed Random from the clock." This is the one that feels like a fix and is a downgrade:
Random(Clock.System.now().toEpochMilliseconds()) makes the id derivable from the time it was
created, which is strictly more guessable than before.
"Write a UUIDv4 implementation in commonMain." I did, and it is correct about the format and silent about the source. A UUIDv4 built on a non-CSPRNG is a correctly-formatted guessable identifier — the version nibble does not add entropy.
"Use a multiplatform UUID library." Reasonable, and the question just moves: which random
source does it use on each target? The good ones use the platform CSPRNG, which means the answer
was always a platform seam. (Kotlin's own kotlin.uuid.Uuid, now available, is the right default —
and knowing what it is doing is what lets you verify it.)
"Let the server generate everything." Correct for the share token and impossible for the optimistic insert, which exists precisely because the server has not answered yet.
The actual mechanism
Three separate requirements get bundled into "an id".
Uniqueness — two calls must not collide. Needs enough entropy or a coordination mechanism.
Unpredictability — an observer must not be able to guess the next one. Needs a CSPRNG, and nothing else will do.
Ordering — ids that sort by creation time give database locality and cheap pagination. Needs a timestamp component, which reduces unpredictability.
The third requirement actively conflicts with the second, which is why "just use UUIDv7 everywhere" is not the universal answer it is sometimes presented as. A v7 id embeds a millisecond timestamp in its leading bits, so anyone holding one learns when it was created and can bound the range of ids issued around it. For a primary key that is a feature — sequential inserts, a clustered index that does not fragment. For a password-reset token or an unguessable-URL share link it is a disclosure, and the entropy you have left is whatever sits after the timestamp rather than the full 122 bits the version number implies.
So the three requirements sort into two different ids, and most systems need both: a v7 for the row, and a separately generated full-entropy token for anything a user might be handed. Using one for both means either accepting a guessable secret or a fragmenting index, and neither failure announces itself at the point the decision is made.
The last two are in genuine tension: a sortable id leaks its creation time by design. So there is no single correct id type — there are different ids for different jobs.
And the random source is per platform whether you like it or not:
kotlin.random.Random — a general-purpose PRNG. Documented as not cryptographically secure.
Default seeding is platform-specific and, as we found, can repeat on a fresh wasm instance.
Platform CSPRNGs — SecureRandom on JVM/Android, SecRandomCopyBytes on Apple platforms,
crypto.getRandomValues in a browser. Each documented, each OS-entropy-backed.

An id generator is a security interface wearing the costume of a utility. The question is not "how do I make a UUID" but "which of uniqueness, unpredictability and ordering does this particular id need".
The fix
Two functions with different names, different sources, and the tension made explicit.
// commonMain — the platform seam. One line per target, and the only source of entropy.
expect fun secureRandomBytes(size: Int): ByteArray
// androidMain / jvmMain
actual fun secureRandomBytes(size: Int) = ByteArray(size).also { SecureRandom().nextBytes(it) }
// iosMain
actual fun secureRandomBytes(size: Int) = ByteArray(size).also { out ->
out.usePinned { SecRandomCopyBytes(kSecRandomDefault, size.convert(), it.addressOf(0)) }
}
// wasmJsMain → crypto.getRandomValues
Then the two id types:
// commonMain
/**
* A LOCAL, time-sortable id. Safe to log, safe in an index, NOT a secret —
* it leaks its creation time by design. Use for rows, cache keys, optimistic inserts.
*/
fun newLocalId(now: Instant): String {
val ms = now.toEpochMilliseconds()
val rand = secureRandomBytes(10)
return buildString {
append(ms.toString(36).padStart(10, '0')) // sortable prefix
rand.forEach { append((it.toInt() and 0xff).toString(16).padStart(2, '0')) }
}
}
/**
* An UNGUESSABLE token. 256 bits from the platform CSPRNG. Never logged,
* never derived from time, no ordering. Use for share links, invites, idempotency keys.
*/
fun newSecretToken(): String =
secureRandomBytes(32).joinToString("") { (it.toInt() and 0xff).toString(16).padStart(2, '0') }
Four things this gets right:
The names carry the contract. newSecretToken() in a share URL reads correctly;
newLocalId() in a share URL reads wrong. That is the whole defence against the next person
reaching for whichever function exists.
The doc comments state what leaks. newLocalId leaking creation time is a property, not a
flaw — and writing it down is what stops someone using it for a token later.
Both use the CSPRNG. There is no reason for the local id to use a weak source; one seam, used everywhere, means there is no weak source in the codebase at all.
The clock is injected. newLocalId(now) takes an Instant rather than reading the wall clock,
so the sortable prefix is testable — the same injectability lesson as everywhere else.
If you are on a recent Kotlin version, prefer the standard library for the token case:
@OptIn(ExperimentalUuidApi::class)
fun newSecretToken(): String = Uuid.random().toString() // platform CSPRNG-backed
And for idempotency keys specifically, the key should be stable across retries — generated once when the operation is created, stored with it, and reused:
// commonMain
data class PendingCharge(val id: String = newLocalId(now), val idempotencyKey: String = newSecretToken())
A fresh key per retry defeats the entire mechanism, which is a bug the original single-helper design made easy to write.
How we proved it
That the old generator repeated. The finding that started this, reduced to a test:
// commonTest
@Test
fun defaultRandomIsReproducibleFromASeed() {
val a = (1..32).map { "0123456789abcdef".random(Random(42)) }.joinToString("")
val b = (1..32).map { "0123456789abcdef".random(Random(42)) }.joinToString("")
assertEquals(a, b) // identical. By design — it is not a CSPRNG.
}
The test passing is the evidence. A generator that reproduces from a seed is documented behaviour and disqualifying for a token.
That the new source does not. Not a proof of cryptographic quality — a basic distinctness and distribution sanity check, run on every target:
@Test
fun secureTokensAreDistinctAcrossManyDraws() {
val seen = HashSet<String>()
repeat(50_000) { assertTrue(seen.add(newSecretToken())) }
}
@Test
fun secureBytesAreNotAllZeroOrConstant() {
val draws = (1..100).map { secureRandomBytes(32).toList() }
assertEquals(100, draws.distinct().size)
assertTrue(draws.flatten().distinct().size > 200) // byte values well spread
}
jvmTest(desktop) PASSED
androidUnitTest PASSED
iosSimulatorArm64Test PASSED
wasmJsTest PASSED ← the target that was repeating
That sortable ids fixed the index problem:
10,000 inserts then a paged query by id
random hex 340ms
newLocalId (sortable prefix) 12ms
And that ordering actually holds, which is the property the prefix exists for:
@Test
fun localIdsSortByCreationTime() {
val t0 = Instant.parse("2026-10-18T10:00:00Z")
val ids = (0..100).map { newLocalId(t0 + it.seconds) }
assertEquals(ids, ids.sorted())
}
Two honest limits. These tests show distinctness and distribution, not cryptographic strength — that comes from using the platform provider, which is an argument from provenance rather than from measurement, and it is the correct kind of argument here. And the base-36 millisecond prefix sorts correctly only while the encoded length is stable, which holds for several centuries but is a real assumption rather than a universal truth.
What this generalizes to
A utility that encodes a security property should say so in its name. Ids.new() told a reader
nothing, so it got used for three jobs with three different requirements. Naming is the cheapest
enforcement mechanism available.
Separate requirements that are in tension instead of compromising. Sortability and unpredictability cannot both be maximised. Two functions, each honest about what it gives up, beats one that is mediocre at both.
Check the documented guarantees of your random source, every time. "Random" is a word covering a seeded reproducible PRNG and an OS-entropy CSPRNG. Which one you have decides whether an identifier is a secret, and the two have identical-looking output.
Correct format is not a security property. A well-formed UUIDv4 from a weak source is a weak token that passes every validator. Format tells you about parsing; provenance tells you about strength.
Related
- Hashing a password in shared code is the one thing you should not share — the same seam, for primitives
- kotlinx-datetime has no Calendar, and the timezone database ships per platform — why the clock is injected
- There is no File in commonMain, and that is a feature — naming by capability
Keep reading
This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".