Logo

dev-resources.site

for different kinds of informations.

Making Beautiful API Keys

Published at
1/10/2025
Categories
go
api
opensource
webdev
Author
savannah_copland_e2136640
Categories
4 categories in total
go
open
api
open
opensource
open
webdev
open
Author
25 person written this
savannah_copland_e2136640
open
Making Beautiful API Keys

tl;dr - Since developers are our customers, we wanted them to have beautiful API keys. We couldn't find a suitable standard solution so we made our own package - uuidkey - that you can use to encode & format UUIDs into human-readable keys. If you use UUIDv7, you can also decode the keys to store them as sortable, indexable IDs in your database.

The Problem

API keys are a large part of a user's first interaction with our product at AgentStation, and we want to make a good impression. We want to make our keys look good and "feel" good, but there seems to be no "good" standard in the industry.

We are a busy startup, but we are also a developer-first platform company. Therefore, we thought it made sense to put some time, thought and effort into figuring out a solution we (and hopefully our developers) would be happy with.

Most API Keys Suck

We came up with a list of requirements for our API keys:

  • Secure
  • Globally unique
  • Sortable
  • Performant in Postgres
  • Nice to look at

Unfortunately, most API keys are ugly. They're often just random strings of characters with inconsistent formatting, making them hard to read, sort, and identify.

As most beautiful things in life are symmetrical - we wanted to bring symmetry to our API keys.

The IDs we Rejected 🙅

Too random, too guessable, too ugly... none were just right

Integers and Bigints

Simple, readable, and easy to sort. But they reveal how many keys exist and are easily guessable - not great for security.

NanoIDs

NanoIDs offer fully random, customizable IDs. They're particularly suited for public-facing identifiers (we even use them for our public IDs1). But they lack timestamp information that's useful for sorting and debugging.

id, _ := publicid.New()
fmt.Println("Generated default public ID:", id)
// Output: Generated default public ID: Ab3xY9pQ
Enter fullscreen mode Exit fullscreen mode

UUIDs

UUIDs are the industry standard, with two versions worth considering for API keys2:

  • UUIDv4: Purely random characters. Simple but effective.
  • UUIDv7: Includes timestamps, which we love because:
    • It embeds creation time, useful for debugging without database lookups
    • It enables efficient database queries and chronological sorting.

Overall, developers like to use UUIDs - they just don't like how they look.

ULIDs

ULIDs were close to what we wanted. They include timestamps and use Base32 encoding for better readability. But we preferred UUID's native Postgres support (more on this below), and were still not quite satisfied with the aesthetics.

ulid.New().String() // 01H9ZQPS8ZQXV4RNJQ6PDNQ4FX
Enter fullscreen mode Exit fullscreen mode

Our Solution

As none of the options were sufficiently beautiful (symmetrical), we created our own approach:

  1. Use UUIDv7 as the base ID to leverage timestamps
  2. Encode the ID using Crockford Base32 for readability
  3. Add artfully placed dashes for aesthetics

The result:

key, _ := uuidkey.Encode("d1756360-5da0-40df-9926-a76abff5601d")
fmt.Println(key) // Output: 38QARV0-1ET0G6Z-2CJD9VA-2ZZAR0X
Enter fullscreen mode Exit fullscreen mode

Our keys are:

  • 31 characters (28 without dashes) vs UUID's 36
  • Highly readable segments with 4 sets of 7 uppercase letters and numbers for "blocky" aesthetics and readability
  • Chronologically sortable when stored decoded as UUIDs
  • Obfuscated timestamps in the user-facing key (but yes a savvy user could still decode it). We find timestamp metadata in the key to be a bonus, you can always use UUIDv4 instead! You do you boo! 👻

Why UUIDv7?

Beyond the timestamp benefits, UUIDv7 will get native Postgres support in v183. While you can use extensions to generate UUIDv7s server side for now, the native Postgres support will certainly be more performant4, and will work great to pass to uuidkey.Encode().

For our implementation, we're currently generating keys in the application layer and storing them as UUIDs for sorting and indexing. Once Postgres v18 is released, we'll switch to Postgres generation to offload that dataflow from our application layer and have slightly better performance.

Why Crockford Base32?

We chose Crockford Base32 encoding because it:

  • Uses only uppercase letters and numbers, which improves readability5
  • Reduces key length by ~1/5
  • Mapping is performant and predictable
  • Is what all the cool kids are using 😎

Why Dashes?

The resulting dashed keys are "blocky" and symmetrical. If you were to gray out the individual characters, they almost look like a barcode. We think it makes it easy to quickly read portions of a key to identify it.

The dashes do remove easy double-click copying, but we think this a fine trade off for readability. We don't want users copying and pasting them everywhere, in fact we want them to be handled with care. Ideally, users copy each key exactly once - when they generate the key from our dashboard - so we added a copy button to our UI to solve that case.

The uuidkey Package

We open sourced these design choices at github.com/agentstation/uuidkey. If you agree with our aesthetics, reasoning, symmetry, and want beautiful API keys of your own, you are welcome to play around with our open source project.

The heart of the uuidkey package encodes UUIDs to a readable Key format via the Base32-Crockford codec and also decodes them back to UUIDs.

Encode

// Encode will encode a given UUID string into a Key with basic length validation.
func Encode(uuid string) (Key, error) {
    if len(uuid) != UUIDLength { // basic length validation to ensure we can encode
        return "", fmt.Errorf("invalid UUID length: expected %d characters, got %d", UUIDLength, len(uuid))
    }

    // select the 5 parts of the UUID string
    s1 := uuid[0:8]   // [d1756360]-5da0-40df-9926-a76abff5601d
    s2 := uuid[9:13]  // d1756360-[5da0]-40df-9926-a76abff5601d
    s3 := uuid[14:18] // d1756360-5da0-[40df]-9926-a76abff5601d
    s4 := uuid[19:23] // d1756360-5da0-40df-[9926]-a76abff5601d
    s5 := uuid[24:36] // d1756360-5da0-40df-9926-[a76abff5601d]

    // decode each string part into uint64
    n1, _ := strconv.ParseUint(s1, 16, 32)
    n2, _ := strconv.ParseUint(s2+s3, 16, 32)     // combine s2 and s3
    n3, _ := strconv.ParseUint(s4+s5[:4], 16, 32) // combine s4 and the first 4 chars of s5
    n4, _ := strconv.ParseUint(s5[4:], 16, 32)    // the last 8 chars of s5

    // encode each uint64 into base32 crockford encoding format
    e1 := encode(n1)
    e2 := encode(n2)
    e3 := encode(n3)
    e4 := encode(n4)

    // build and return key
    return Key(e1 + "-" + e2 + "-" + e3 + "-" + e4), nil
}
Enter fullscreen mode Exit fullscreen mode

Decode

// Decode will decode a given Key into a UUID string with basic length validation.
func (k Key) Decode() (string, error) {
    if len(k) != KeyLength { // basic length validation to ensure we can decode
        return "", fmt.Errorf("invalid Key length: expected %d characters, got %d", KeyLength, len(k))
    }

    // select the 4 parts of the key string
    key := string(k) // convert the type from a Key to string
    s1 := key[0:7]   // [38QARV0]-1ET0G6Z-2CJD9VA-2ZZAR0X
    s2 := key[8:15]  // 38QARV0-[1ET0G6Z]-2CJD9VA-2ZZAR0X
    s3 := key[16:23] // 38QARV0-1ET0G6Z-[2CJD9VA]-2ZZAR0X
    s4 := key[24:31] // 38QARV0-1ET0G6Z-2CJD9VA-[2ZZAR0X]

    // decode each string part into original UUID part string
    n1 := decode(s1)
    n2 := decode(s2)
    n3 := decode(s3)
    n4 := decode(s4)

    // select the 4 parts of the decoded parts
    n2a := n2[0:4]
    n2b := n2[4:8]
    n3a := n3[0:4]
    n3b := n3[4:8]

    // build and return UUID string
    return (n1 + "-" + n2a + "-" + n2b + "-" + n3a + "-" + n3b + n4), nil
}
Enter fullscreen mode Exit fullscreen mode

A big shoutout to richardlehane/crock32 for the solid implementation of encoding and decoding crockford base32 operations.

The package is designed to work with any UUID that follows the official UUID specification (RFC 4122), but we specifically test and maintain compatibility with the two most popular UUID Go generators:6

Installation is straightforward:

go get github.com/agentstation/uuidkey
Enter fullscreen mode Exit fullscreen mode

Basic usage:

key, _ := uuidkey.Encode("d1756360-5da0-40df-9926-a76abff5601d")
fmt.Println(key) // 38QARV0-1ET0G6Z-2CJD9VA-2ZZAR0X
Enter fullscreen mode Exit fullscreen mode

We've put in effort to keep the overhead minimal:

BenchmarkValidate-12           33527211    35.72 ns/op
BenchmarkParse-12              32329798    36.96 ns/op
BenchmarkEncode-12             3151844     377.0 ns/op
BenchmarkDecode-12             5587066     216.7 ns/op
Enter fullscreen mode Exit fullscreen mode

Contributing to uuidkey

We're committed to maintaining uuidkey as a reliable open source tool since we use it in production - contributions are welcome!

If you find it useful or have ideas for improvements, we'd love to hear from you in our GitHub issues or Discord community.

🎨 Prior Art & 🗿 Shoulders of Giants

After we published our project, we found a few others with similar implementations but still lacked meeting our criteria for encoding and decoding UUIDs using Go.

  • uuidapikey - Go, but does not support encoding or decoding UUID input
  • based_uuid - Ruby, but for Public IDs

Wrap Up

At AgentStation, we're building a platform where AI agents get their own virtual workstations to run browsers, join meetings, and execute code. As we scale to thousands of workstations, having sortable, performant keys is practical infrastructure.

But we also believe developers appreciate beautiful symmetrical things like we do, even API keys.

We hope you find uuidkey useful and beautiful.

Want to build AI agents with us? We're taking beta users. Sign up for early access.


  1. We opened sourced our package, publicid, for generating and validating NanoID strings designed to be publicly exposed. 

  2. There's actually eight versions of UUIDs, but all of them suck except for v4 and v7: 

    • UUIDv1: Based on timestamp and MAC address. These are particularly bad since MAC addresses can be used to track devices. Plus, if you're generating UUIDs across multiple machines, you need to ensure each has a unique MAC address or risk collisions. Uses node identifiers and nanosecond precision timestamps.
    • UUIDv2: This version was never fully standardized and is practically unused. It was supposed to be for DCE Security, but nobody actually implements it.
    • UUIDv3: Uses MD5 hashing of a namespace and name. It's deterministic, which means if someone knows your namespace and name, they can recreate your IDs.
    • UUIDv4: Based on random characters. Random is nice, very low likelihood of collisions.
    • UUIDv5: Similar to v3 but uses SHA-1 instead of MD5. SHA-1 is better than MD5, but the deterministic nature still makes it a no-go for API keys.
    • UUIDv6: A k-sortable id based on timestamp, and field-compatible with v1. It's a better v1, but still contains the MAC address privacy issues. Like v1, it uses node identifiers and nanosecond precision timestamps.
    • UUIDv7: A k-sortable id based on timestamp. Instead of tracking nodes and nanoseconds, it uses millisecond precision (because let's be real, that's what most systems have) and throws in 74 random bits to avoid collisions. If you've got better clocks, you can trade 12 of those random bits for more precise timing or sequence numbers.
    • UUIDv8: A "custom" UUID format that lets you define your own timestamp format and bit layout. It's meant for cases where other versions don't quite fit your needs - like if you need a different timestamp precision or want to embed additional data.
  3. UUIDv7 support was supposed to be included in v17, but the RFC wasn't finalized in time. Sad! 

  4. Dave Allie's blog post on his ULID implementation shares a benchmark of the performance of a custom function for generating ULIDs versus the native Postgres gen_random_uuid() function, which generates UUIDv4. The custom function was 73% slower in generating 10 million values, and 31% slower when generating and inserting 1 million values. While we don't know how UUIDv7 will perform natively, it's likely to be faster than a custom function or extension. 

  5. The Crockford Base32 encoding uses a 32 character symbol set - all 10 digits, and 22/26 capital letters, excluding I, L, O, and U. It was designed to allow for verbal communication of public keys, which is why letters that can be confused with numbers were removed (0 and O, I/l and 1). Allegedly, U was removed to reduce the chance of "accidental obscenity", but we suspect it was because Crockford wanted an even 32 symbol set. 

  6. Historically, people loved the Google package because it just worked. However, after years of wear and tear with many different UUID key versions it's API patterns became a bit of a mess to use. 

go Article's
30 articles in total
Favicon
A técnica dos dois ponteiros
Favicon
Preventing SQL Injection with Raw SQL and ORM in Golang
Favicon
🐹 Golang Integration with Kafka and Uber ZapLog 📨
Favicon
🌐 Building Golang RESTful API with Gin, MongoDB 🌱
Favicon
Golang e DSA
Favicon
Prevent Race Conditions Like a Pro with sync.Mutex in Go!
Favicon
tnfy.link - What's about ID?
Favicon
Developing a Simple RESTful API with Gin, ginvalidator, and validatorgo
Favicon
Desbravando Go: Capítulo 1 – Primeiros Passos na Linguagem
Favicon
Compile-Time Assertions in Go (Golang)
Favicon
Mastering GoFrame Logging: From Zero to Hero
Favicon
GoFr: An Opinionated Microservice Development Framework
Favicon
The Struggle of Finding a Free Excel to PDF Converter: My Journey and Solution
Favicon
Setting Up Your Go Environment
Favicon
External Merge Problem - Complete Guide for Gophers
Favicon
Mastering Go's encoding/json: Efficient Parsing Techniques for Optimal Performance
Favicon
Golang with Colly: Use Random Fake User-Agents When Scraping
Favicon
Versioning in Go Huma
Favicon
Go Basics: Syntax and Structure
Favicon
Interesting feedback on Fuego!
Favicon
Making Beautiful API Keys
Favicon
Building a Semantic Search Engine with OpenAI, Go, and PostgreSQL (pgvector)
Favicon
Go's Concurrency Decoded: Goroutine Scheduling
Favicon
Golang: Struct, Interface And Dependency Injection(DI)
Favicon
Desvendando Subprocessos: Criando um Bot de Música com Go
Favicon
go
Favicon
🚀 New Article Alert: Master sync.Pool in Golang! 🚀
Favicon
Week Seven Recap of #100DaysOfCode
Favicon
Ore: Advanced Dependency Injection Package for Go
Favicon
Golang vs C++: A Modern Alternative for High-Performance Applications

Featured ones: