25 comments

[ 3.6 ms ] story [ 52.9 ms ] thread
Hi, I’m the author of uuidv47. The idea is simple: keep UUIDv7 internally for database indexing and sortability, but emit UUIDv4-looking façades externally so clients don’t see timing patterns.

How it works: the 48-bit timestamp is XOR-masked with a keyed SipHash-2-4 stream derived from the UUID’s random field. The random bits are preserved, the version flips between 7 (inside) and 4 (outside), and the RFC variant is kept. The mapping is injective: (ts, rand) → (encTS, rand). Decode is just encTS ⊕ mask, so round-trip is exact.

Security: SipHash is a PRF, so observing façades doesn’t leak the key. Wrong key = wrong timestamp. Rotation can be done with a key-ID outside the UUID.

Performance: one SipHash over 10 bytes + a couple of 48-bit loads/stores. Nanosecond overhead, header-only C11, no deps, allocation-free.

Tests: SipHash reference vectors, round-trip encode/decode, and version/variant invariants.

Curious to hear feedback!

My biggest concern is the entropic quality of the random bits, since the design of UUIDv7 is fundamentally more concerned with collisions than predictability; consequently, although the standard says SHOULD for their nonguessability it isn't a MUST, and leaves room for implementations that use a weak PRNG, or that increment a counter, or even place additional clock data in the apparently random bits (ref. RFC9562 s6.2 & s6.9).

So there's definitely some gotchas with relying on rand_a and rand_b in UUIDv7 for seeding a PRF, and when ingesting data from devices outside of your trust boundary (as may be the case with high-volume telemetry), even if you wrote the code they basically can't be trusted for this purpose, and if those bits are undisturbed in the output it's certainly a problem if the idea was to obfuscate serialisation, timing, or correlation.

Even generations we might assume are safe may not be completely safe; for example, the new uuidv7() in PostgreSQL 18 fills rand_a entirely from the high precision part of the timestamp, and this is RFC compliant. So if an import routine generates a big batch of such UUIDs, this v7-to-v4 scheme discloses output bits that can be used to relate individual records as part of the same group. That might be fine for data points pertaining to a vehicle engine. It might not be fine for identifiers that relate to people.

So, since not all UUIDv7 is created alike, I'd add a strong caveat: unless generating the rand_a and rand_b bits entirely oneself with a high degree of confidence in their nonguessibility, then this scheme may still leak information regarding timing, sequence, or correlation of records, and you will have to read the source code of your UUIDv7 implementation to know for sure.

This is interesting, but is almost something I'd rather have the DB handle for me - i.e. I can cast a UUIDv7 to "UUIDv4" (and vice versa) and I could use both in queries (with explicit syntax to annotate which kind is being used / expected)
Interesting project... just out of curiosity, could you give something resembling a couple practical examples of the risk of exposing the time portion of a v7 UUID?
My biggest issue w/ UUIDv7 is how challenging they are to visually diff when looking at a list. Having some sort of visual translation layer in psql that would render them with the random bits first while maintaining the time sorting underneath would be a major UX boost...
A few years ago I made a scheme whereby you could use sequential numeric IDs in your database, but expose them as short random strings (length 4–20 step 2, depending on numeric value and sparsity configuration). It used some custom instances of the Speck cipher family, and I think it’s robust and rather neat.

Although I finished it, I never quite published it properly for some reason, probably partly because I shelved the projects where I had been going to use it (I might unshelve one of them next year).

Well, I might as well share it, because it’s quite relevant here and interesting:

https://temp.chrismorgan.info/2025-09-17-tesid/

My notes on its construction, pros and cons are fairly detailed.

Maybe I’ll go back and publish it properly next year.

I was interested in something similar with Speck for obfuscating bigserial PKIDs but the shortage of cross-platform implementations - especially in pgcrypto - led to choosing base58(AES_K1(id{8} || HMAC_K2(id{8})[0..7])) instead, which we could implement in almost anything and is performant enough, albeit longer output (typically 22 characters)
I remember doing something similar, but I just used two columns, a public uuid, and a bigint primary key that wasn't exposed to the api (this was long before uuidv7). Lacked a lot of the conveniences of using uuid everywhere, but it still handled the use case of merging different DB dumps as long as PKs were stripped out first.

And maybe I misunderstand how the hashing works, but it seems if you're looking things up by the hashed uuid, you're still going to want two columns anyway.

The conversion is reversible using the secret cryptographic key so you can turn the uuidv4s from requests into your db uuidv7s.
i'm curious, if you're doing single header, why not also do the stb-style IMPL block + definitions block such that you avoid the issues from accidentally including the header multiple times?
Mobile apps often sort by creation time in the UI (chat messages, activity feeds). Since clients only see the masked version, there might be a need to expose a separate timestamp field.
Why not just use UUIDv8? The format allows you to use the upper bits for a timestamp and the lower bits for any value you like, including just a random value.
this is solved by reading the repo's README: hiding timing information.
I started encrypting database IDs and deriving GUIDs from that.
This is cool, but the entire “OMG you can’t leak timestamps” has always reeked of security theater to me, as has the argument that if you expose sequential IDs, you’re opening vectors of attack, exposing business information, etc.

Add some random large value to your ints periodically - they’ll still be monotonic, but you’ll throw off the dastardly spies stealing your super valuable business intelligence.

Why does it use version 4 instead of version 8? Version 4 implies that it's random bits, but it's actually not random. Version 8 doesn't imply anything about what the bits mean.
Why not use a different encryption key per session and stamp encrypted ids (or whatever info) to the outside word.

This way the DBs can use simple sequence numbers instead of timestamp based IDs.

You have to know what key to use to decrypt the timestamp bits of the token. If you change keys regularly you have the problem of keeping lots of keys, as well as somehow determining the right key
Before using this....

Consider what you'll do if someone ever gets root in your web server and leaks the key.

Suddenly all your UUID's need to be replaced. That tends to be impossible since they're probably part of published URL's etc.

Big companies have made similar mistakes - that's probably why for example all private YouTube videos and Google docs had their links invalidated a few years back when the key security of a decade old key couldn't be certain and the key wasn't rotatable.

TL;DR: Never use anything where you cannot rotate a key, including this.