Unique identifiers play a crucial role in all applications, from user authentication to resource management. While using a standard UUID will satisfy all your security concerns, there’s a lot we can improve for our users.
Written by
Andreas Thomas
Published on
TLDR: Please don't do this:
1https://company.com/resource/c6b10dd3-1dcf-416c-8ed8-ae561807fcaf
Unique identifiers are essential for distinguishing individual entities within a system. They provide a reliable way to ensure that each item, user, or piece of data has a unique identity. By maintaining uniqueness, applications can effectively manage and organize information, enabling efficient operations and facilitating data integrity.
Let’s not pretend like we are Google or AWS who have special needs around this. Any securely generated UUID with 128 bits is more than enough for us. There are lots of libraries that generate one, or you could fall back to the standard library of your language of choice. In this blog, I'll be using Typescript examples, but the underlying ideas apply to any language.
1const id = crypto.randomUUID();
2// '5727a4a4-9bba-41ae-b7fe-e69cf60bb0ab'
Stopping here is an option, but let's take the opportunity to enhance the user experience with small yet effective iterative changes:
Try copying this UUID by double-clicking on it:
1c6b10dd3-1dcf-416c-8ed8-ae561807fcaf
If you're lucky, you got the entire UUID but for most people, they got a single section. One way to enhance the usability of unique identifiers is by making them easily copyable. This can be achieved by removing the hyphens from the UUIDs, allowing users to simply double-click on the identifier to copy it. By eliminating the need for manual selection and copy-pasting, this small change can greatly improve the user experience when working with identifiers.
Removing the hyphens is probably trivial in all languages, here’s how you can do it in js/ts:
1const id = crypto.randomUUID().replace(/-/g, "");
2// fe4723eab07f408384a2c0f051696083
Try copying it now, it’s much nicer!
Have you ever accidentally used a production API key in a development environment? I have, and it’s not fun.
We can help the user differentiate between different environments or resources within the system by adding a meaningful prefix. For example, Stripe uses prefixes like sk_live_
for production environment secret keys or cus_
for customer identifiers. By incorporating such prefixes, we can ensure clarity and reduce the chances of confusion, especially in complex systems where multiple environments coexist.
1const id = `hello_${crypto.randomUUID().replace(/-/g, "")}`;
2// hello_1559debea64142f3b2d29f8b0f126041
Naming prefixes is an art just like naming variables. You want to be descriptive but be as short as possible. I'll share ours further down.
Instead of using a hexadecimal representation for identifiers, we can also consider encoding them more efficiently, such as base58. Base58 encoding uses a larger character set and avoids ambiguous characters, such as upper case I
and lower case l
resulting in shorter identifier strings without compromising readability.
As an example, an 8-character long base58 string, can store roughly 30.000 times as many states as an 8-char hex string. And at 16 chars, the base58 string can store 889.054.070 as many combinations.
You can probably still do this with the standard library of your language but you could also use a library like nanoid which is available for most languages.
1import { customAlphabet } from "nanoid";
2export const nanoid = customAlphabet(
3 "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz",
4);
5
6const id = `prefix_${nanoid(22)}`;
7// prefix_KSPKGySWPqJWWWa37RqGaX
We generated a 22 character long ID here, which can encode ~100x as many states as a UUID while being 10 characters shorter.
Characters | Length | Total States | |
---|---|---|---|
UUID | 16 | 32 | 2^122 = 5.3e+36 |
Base58 | 58 | 22 | 58^22 = 6.2e+38 |
The more states, the higher your collision resistance is because it takes more generations to generate the same ID twice (on average and if your algorithm is truly random)
Not all identifiers need to have a high level of collision resistance. In some cases, shorter identifiers can be sufficient, depending on the specific requirements of the application. By reducing the entropy of the identifiers, we can generate shorter IDs while still maintaining an acceptable level of uniqueness.
Reducing the length of your IDs can be nice, but you need to be careful and ensure your system is protected against ID collissions. Fortunately, this is pretty easy to do in your database layer. In our MySQL database we use IDs mostly as primary key and the database protects us from collisions. In case an ID exists already, we just generate a new one and try again. If our collision rate would go up significantly, we could simply increase the length of all future IDs and we’d be fine.
Length | Example | Total States |
---|---|---|
nanoid(8) | re6ZkUUV | 1.3e+14 |
nanoid(12) | pfpPYdZGbZvw | 1.4e+21 |
nanoid(16) | sFDUZScHfZTfkLwk | 1.6e+28 |
nanoid(24) | u7vzXJL9cGqUeabGPAZ5XUJ6 | 2.1e+42 |
nanoid(32) | qkvPDeH6JyAsRhaZ3X4ZLDPSLFP7MnJz | 2.7e+56 |
By implementing these improvements, we can enhance the usability and efficiency of unique identifiers in our applications. This will provide a better experience for both users and developers, as they interact with and manage various entities within the system. Whether it's copying identifiers with ease, differentiating between different environments, or achieving shorter and more readable identifier strings, these strategies can contribute to a more user-friendly and robust identification system.
Lastly, I'd like to share our implementation here and how we use it in our codebase. We use a simple function that takes a typed prefix and then generates the ID for us. This way we can ensure that we always use the same prefix for the same type of ID. This is especially useful when you have multiple types of IDs in your system.
1import { customAlphabet } from "nanoid";
2export const nanoid = customAlphabet(
3 "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz",
4);
5
6const prefixes = {
7 key: "key",
8 api: "api",
9 policy: "pol",
10 request: "req",
11 workspace: "ws",
12 keyAuth: "key_auth", // <-- this is internal and does not need to be short or pretty
13 vercelBinding: "vb",
14 test: "test", // <-- for tests only
15} as const;
16
17export function newId(prefix: keyof typeof prefixes): string {
18 return [prefixes[prefix], nanoid(16)].join("_");
19}
And when we use it in our codebase, we can ensure that we always use the correct prefix for the correct type of id.
1import { newId } from "@unkey/id";
2
3const id = newId("workspace");
4// ws_dYuyGV3qMKvebjML
5
6const id = newId("keyy");
7// invalid because `keyy` is not a valid prefix name
I've been mostly talking about identifiers here, but an api key really is just an identifier too. It's just a special kind of identifier that is used to authenticate requests. We use the same strategies for our api keys as we do for our identifiers. You can add a prefix to let your users know what kind of key they are looking at and you can specify the length of the key within reason. Colissions for API keys are much more serious than ids, so we enforce secure limits.
It's quite common to prefix your API keys with something that identifies your company. For example Resend are using re_
and OpenStatus are using os_
prefixes. This allows your users to quickly identify the key and know what it's used for.
1const key = await unkey.key.create({
2 apiId: "api_dzeBEZDwJ18WyD7b",
3 prefix: "blog",
4 byteLength: 16,
5 // ... omitted for brevity
6});
7
8// Created key:
9// blog_cLsvCvmY35kCfchi
2500 verifications and 100K successful rate‑limited requests per month. No CC required.