DEV Community

Anton Brilliantov
Anton Brilliantov

Posted on

Snowflake IDs - Part 2: Across the Wire, the URL, and the Browser

Part 1 settled the primary key: a 64-bit Snowflake in a BIGINT, generated at the edge. This part is about what happens when that id has to leave the trust boundary - into a public URL, a third-party API, a browser. Raw ints don't go out the door, a browser can't count that high without corrupting them, and a leaked link has to be revocable. Here's the whole boundary: hashids at the edge, composite tokens that carry a whole ownership chain, tenant-controlled salt rotation with a leak tripwire, and the one dull rule that saves your data - never send a naked int64 to a browser.


πŸ‘‹ Hi again, I'm Anton - PHP/Symfony and Go, mostly writing about breaking a monolith into services without breaking the business. Part 1 argued for int8 + Snowflake as the primary key and promised the harder half: moving that id across boundaries. That's this post. As in Part 1, I'm not writing any of these primitives by hand - hashids/Sqids libraries exist in every language; this is about where the translation lives and why. Running notes: github.com/brilliant-almazov.

Inside the mesh, the id is just an int64 and life is simple. The trouble is entirely at the edges, and there are exactly three of them: the service boundary (fine, send the int), the public boundary (never send the int), and the browser (can't even hold the int). Get any one wrong and you leak growth numbers, hand out IDOR, or silently corrupt the wrong record.


Inside the trust boundary: raw int8, no games

Between services, the ID is just an int64. gRPC carries it as int64, the proto field is int64, one service hands order_id = 7239344029703798784 to the next and nobody obfuscates anything. Inside the mesh there's no attacker to hide from and every layer of encoding is just latency and confusion. Raw ints flow freely behind the wall.

The public surface is where it all changes.


Outward: raw ints never leave the building

On the public API, in URLs, in anything a browser or a third party sees, the raw int never appears. The one exception is an admin panel that already sits behind authentication - staff can see the real id because they've earned the right to. Everyone else gets a hashid.

A hashid is a reversible, salted encoding of an int64 into a short opaque string - 7239344029703798784 becomes something like kZ9mQ2xR. Encode on the way out, decode on the way in. Off-the-shelf, every language: the classic is hashids (speps/go-hashids in Go, hashids/hashids or vinkla/hashids in PHP); the maintained successor is Sqids (sqids/sqids-go, sqids/sqids-php). Don't write your own - and notice the important property both share: they encode a list of numbers, not just one. Hold that thought, it's the composite trick below.

The salt isn't one global value - it's scoped per tenant, and within a tenant, per entity type. An order has a different salt from a project, which differs from a user, and all of them differ across tenants. So the same internal id encodes to a different string for each customer and each type - no cross-tenant correlation, no cross-type correlation, no "tenant A's id 5 looks like tenant B's id 5." Think of it as a small matrix of salts, (tenant Γ— entity_type), with a tenant-level master salt above it - and every cell in that matrix is independently rotatable. More on why that granularity matters right below.

These are the clean URLs the outside world sees - opaque, tenant-scoped, no naked integers:

                        PUBLIC (browser / API)              INTERNAL (mesh)
  single entity   GET /api/v1/orders/kZ9mQ2xR        ->     order_id = 7239344029703798784
  another tenant  GET /api/v1/orders/7bQ0pLMn        ->     order_id = 7239344029703798784   (same id, different salt)
  admin (authed)  shows  kZ9mQ2xR  =  7239344029703798784   (raw id on purpose, behind login)
Enter fullscreen mode Exit fullscreen mode

Same row, different faces. The public gets the hashid, the mesh gets the int, and the only place both are shown side by side is behind a login.


Clean URLs with per-tenant hashids mapping to raw int64 order ids; an admin row shows both side by side

A public id is not only a hashed number - it can name its own type

Everything above shows a bare token: an opaque string and nothing else, with the URL path (/orders/…) doing the job of saying what kind of thing it points at. There's a better shape. The outward id can be <prefix><token> - a short letter or two in front that names the entity type, and then the salted hashid.

o for an order, c for a comment, p for a project, u for a user, inv for an invoice. The orders example I've been using is just an example: nothing here is orders-specific. Any entity type you expose on the public surface gets its own prefix, and the prefix is the only thing that differs between them.

  bare token       kZ9mQ2xR         the TYPE comes from the route
  prefixed token   oΒ·kZ9mQ2xR       the TYPE comes from the ID itself
                   β”‚ └───────────── the salted hashid: one id, or a whole tuple
                   └─────────────── public alias for an internal entity type

  o kZ9mQ2xR   order     ->  decode with the (tenant Γ— order)   salt cell
  c 7bQ0pLMn   comment   ->  decode with the (tenant Γ— comment) salt cell
  p 4hT1nW8s   project   ->  decode with the (tenant Γ— project) salt cell

  one generic route is now enough:  GET /api/v1/r/o kZ9mQ2xR
Enter fullscreen mode Exit fullscreen mode

The prefix makes the id self-describing at the edge

This reverses the usual order of operations. Normally you decode first and then discover what you're holding. With a prefix, the gateway knows three things from the first character, before it decodes anything:

  1. what kind of object the token points at,
  2. which salt cell to decode it with - the (tenant Γ— entity_type) cell from the matrix above,
  3. which handler owns it.

And it closes a whole class of confusion for free: a token minted for one type cannot be replayed as another. Feed an o… token to the comment path and two independent things disagree at once - the prefix says order, and the comment salt cell doesn't decode it anyway. There's no "close enough" outcome; it fails closed.

// The prefix is read BEFORE the decode: it selects the salt cell and the owner.
// The mapping is CONFIG, resolved per tenant - never a switch statement in code.
func (g *Gateway) DecodePublic(tenant TenantID, publicID string) (EntityType, []ID, error) {
    prefixes := g.prefixes.For(tenant)

    prefix, token, ok := prefixes.Split(publicID) // "okZ9mQ2xR" -> "o", "kZ9mQ2xR"
    if !ok {
        return "", nil, ErrUnknownID
    }
    typ, ok := prefixes.Type(prefix) // "o" -> order, "c" -> comment (this tenant's map)
    if !ok {
        return "", nil, ErrUnknownID // unknown prefix: reject, never try the other cells
    }

    // Same resolution chain as before - the prefix just told us which cell to use.
    ids, meta, err := g.codecs.For(tenant).Type(typ).Decoder().Decode(token)
    if err != nil {
        return "", nil, ErrUnknownID // prefix and salt cell disagree -> not ours
    }
    if meta.SaltAge > 0 && !g.policy.Accepts(tenant, typ, meta.SaltAge) {
        return "", nil, ErrLinkRevoked
    }
    return typ, ids, nil // authorization still runs after this. Always.
}
Enter fullscreen mode Exit fullscreen mode

Prefixes are configuration, not code

The prefix map is data, not a deploy. It lives per tenant and is editable from the admin panel - the same self-serve surface that already rotates salts in this article. One tenant's orders are o, another's are x7, and neither needed an engineer.

That's safe for exactly the reason rotation is safe: changing a prefix changes only the outward encoding, never the stored BIGINT. No migration, no data touched, no deploy. It's the same property the whole design keeps cashing in - the id underneath is a stable int8, so everything about how it looks outside is a configuration surface.

With the same obligation attached: a prefix map needs the same history discipline as a salt. Links already in the wild carry the old prefix, so a renamed prefix has to stay resolvable, with the same three strategies as a retired salt - accept + audit, grace window, hard cutoff. Rename a prefix without keeping the old mapping and you've broken every bookmark, exactly as a destructive salt rotation would.

This is how the public surface becomes anonymous

Add it all up and the public API stops describing your system. It doesn't expose the numeric id, it doesn't expose the table, it doesn't expose the internal route or the internal type name. What goes out is a public alias for an internal type - deliberately chosen, tenant-scoped, and swappable at will.

Be honest about the kind of protection that is: it's anonymity of shape, not secrecy. Whoever holds a link can see its prefix and, with a handful of links, work out that o and c are two different kinds of thing. What they can't see is which things, how many there are, what the storage looks like, or whether two tenants' o means the same type at all. It hides your internals from a stranger reading a URL; it is not a secret channel, and - like everything else on this page - it is not authorization.


Composite hashids: put the whole ownership chain in one token

Here's the trick the "list of numbers" property unlocks, and it's my favourite. You don't have to encode just one id. Pack the master and the child together - [project_id, config_id], [order_id, line_id] - into a single opaque token.

  naive (two separate hashids)   GET /api/v1/projects/kZ9mQ2xR/configs/7bQ0pLMn
                                 two tokens, two decodes, two lookups, easy to mismatch

  composite (one token)          GET /api/v1/c/9Kd2mQ7rX4
                                       β”‚  decode -> [project_id, config_id]
                                       β–Ό
                                 the token carries the WHOLE chain
Enter fullscreen mode Exit fullscreen mode

On decode you get the whole chain back, so the gateway validates top-down before any handler runs: does the master exist and belong to this tenant? then is the child actually under that master? A tampered or mismatched pair fails the master -> child check at the edge - the handler never sees an inconsistent pair.

// Encoder and decoder are BOTH interfaces, reached through a resolution CHAIN.
// You never touch a salt directly - you resolve down to the thing that owns it:
//   codecs.For(tenant)           -> the tenant's codec set
//          .Type(entityType)     -> the (tenant Γ— type) salt cell
//          .Encoder() / .Decoder()
type Codecs       interface{ For(tenant TenantID) TenantCodecs }
type TenantCodecs interface{ Type(typ EntityType) TypeCodec }
type TypeCodec    interface {
    Encoder() Encoder // encodes with the CURRENT salt
    Decoder() Decoder // decodes across the salt HISTORY (+ audit)
}
type Encoder interface{ Encode(ids ...ID) (string, error) }
type Decoder interface{ Decode(token string) ([]ID, DecodeMeta, error) } // meta carries SaltAge

// A composite [master, child] is keyed by its MASTER type; the route supplies
// the types, they're never guessed. childType names what the 2nd id must be.
func (g *Gateway) DecodeScoped(
    tenant TenantID, masterType, childType EntityType, token string,
) (Scope, error) {
    // resolve the chain: tenant -> master type -> decoder for that (tenant Γ— type) cell.
    dec := g.codecs.For(tenant).Type(masterType).Decoder()
    ids, meta, err := dec.Decode(token) // a token minted for another type/tenant won't decode
    if err != nil || len(ids) != 2 {
        return Scope{}, ErrUnknownID
    }
    if meta.SaltAge > 0 && !g.policy.Accepts(tenant, masterType, meta.SaltAge) {
        return Scope{}, ErrLinkRevoked // retired-salt strategy; audit already fired in Decode
    }
    master, child := ID(ids[0]), ID(ids[1])

    // Ownership is an authorization, not an existence check (see next section).
    if !g.owns.Is(ctx, tenant, masterType, master) {    // master is THIS tenant's, THIS type?
        return Scope{}, ErrForbidden
    }
    if !g.owns.ChildOf(ctx, master, childType, child) { // child is childType UNDER that master?
        return Scope{}, ErrForbidden
    }
    return Scope{MasterType: masterType, Master: master, ChildType: childType, Child: child}, nil
}

// route wiring makes the types explicit, never guessed:
//   GET /api/v1/projects/{token}/... -> DecodeScoped(tenant, TypeProject, TypeConfig, token)
//   encoding is the same chain: g.codecs.For(tenant).Type(TypeOrder).Encoder().Encode(orderID)
Enter fullscreen mode Exit fullscreen mode

One token, one decode, one ownership chain proven before the handler starts. That's a lot of IDOR surface closed at the door.

A token is a tuple you design - not just a pair

[master, child] is only the simplest case. The real leverage is that a hashid encodes a list of numbers, so you design the schema - pack whatever the edge needs to make one decision from one token. "Do everything through a single link": the URL carries the whole context and the gateway rebuilds it in one decode, with no extra lookups just to learn what the token even points at.

one token  =  a designed tuple of numbers, not a single id

  [ tenant_id | resource_type | resource_id | expiry_ts | nonce ]
       β”‚             β”‚              β”‚             β”‚          β”” one-time guard
       β”‚             β”‚              β”‚             β”” self-expiry
       β”‚             β”‚              β”” what it points at
       β”‚             β”” which KIND of thing (order? invoice? export?)
       β”” who it belongs to  β†’  checked before anything else
Enter fullscreen mode Exit fullscreen mode

Scenario: a self-describing, one-time link

The link that must (a) belong to a tenant, (b) point at exactly one resource, (c) expire, and (d) work exactly once - a share / download / magic link. Put all of it in the token:

GET /api/v1/l/9Kd2mQ7rX4kP… β†’ decode β†’ [tenant_id, resType, resID, expiry, nonce]

and the gateway settles all four checks before any handler runs:

// One self-describing token carries the whole decision. Decode once, check four
// things, serve. The token is a CARRIER; the security is in the server checks.
func (g *Gateway) OpenLink(caller TenantID, token string) (Resource, error) {
    ids, _, err := g.codecs.For(caller).Type(TypeLink).Decoder().Decode(token)
    if err != nil || len(ids) != 5 {
        return Resource{}, ErrUnknownID
    }
    tenant, resType, resID := TenantID(ids[0]), EntityType(ids[1]), ID(ids[2])
    expiry, nonce := time.Unix(int64(ids[3]), 0), ID(ids[4])

    if tenant != caller                    { return Resource{}, ErrForbidden } // (a) ownership
    if g.clock.Now().After(expiry)         { return Resource{}, ErrExpired }   // (c) expiry
    if !g.nonces.Burn(ctx, tenant, nonce)  { return Resource{}, ErrUsed }      // (d) one-time
    return g.load(ctx, tenant, resType, resID)                                 // (b) the resource
}
Enter fullscreen mode Exit fullscreen mode

Be blunt about what the token is and isn't. A hashid/Sqids token is obfuscation, not a signature. It compresses a tuple into an opaque string; it does not prove the tuple wasn't forged by someone who knows the (public) algorithm and guesses the salt. So the security of a one-time link never rests on the encoding:

  • one-time-ness comes from the server-side nonce burn, not from the token;
  • expiry is enforced by comparing to your clock, never trusted from the number;
  • ownership is the authz check from the next section;
  • if the link must be tamper-proof (a capability an attacker must not forge), carry a real HMAC alongside - or use a signed token (PASETO / JWT) for that part. The hashid is the compact envelope; the MAC is the lock.

The composite token is a superb carrier - one URL, all the context, one decode. It is not, by itself, a security mechanism.

Even strings fit: it's numbers all the way down

Hashids encode numbers - but any string is numbers, so a short string can ride in the same token. Two standard routes:

  • Hex / bytes. hashids has encodeHex / decodeHex: feed it a hex string (a UUID's hex, a short byte blob) and it round-trips - the clean way to carry a UUID inside a composite.
  • Char codes. Map each character to its code point and encode the list. In PHP that's ord() per char (chr() to reverse); in Go a []byte / []rune. A short slug or status code becomes just more numbers in the tuple.
// PHP: a short string -> numbers -> hashid, and back
$nums  = array_map('ord', str_split("OPEN"));      // [79, 80, 69, 78]
$token = $hashids->encode(...$nums);
$back  = implode('', array_map('chr', $hashids->decode($token)));  // "OPEN"
// or, for a UUID / raw bytes as hex:
$token = $hashids->encodeHex($uuidHex);            // decodeHex() to reverse
Enter fullscreen mode Exit fullscreen mode

Keep it tiny, though: a hashid grows with the count and size of the numbers, so a composite token is for ids, type codes, timestamps, short flags - not for stuffing blobs. The moment you're tempted to encode a paragraph, you want a real signed token or a lookup key, not a hashid. "Numbers all the way down" is a neat trick; the discipline is keeping the tuple small.


One clean URL carrying a composite token that decodes to a master/child chain, validated top-down before any handler

In a multitenant system, every lookup is an authorization - not an existence check

This is the rule the hashid and the composite token both lean on, and it has to be said explicitly because it's the one that actually keeps tenants apart. Decoding an id to an int64 tells you which row; it never tells you the caller is allowed to see it. So every request, after decode, answers a second question before the handler runs: does this authenticated caller (this tenant, this user) have the right to this object?

"Found vs not found" is not the check. In a multitenant system the dangerous case is exactly the one where the row is found - it's a perfectly real order, it just belongs to a different tenant. If your lookup is SELECT ... WHERE id = ? you've already leaked it; the query has to be WHERE id = ? AND tenant_id = ? (and for a child, AND <master>_id = ?), or the authz has to be a separate, explicit gate that runs regardless. Same for the composite: the master β†’ child validation earlier isn't "do these ids exist," it's "does this tenant own the master, and is the child under that master." A wrong-tenant id that happens to exist must come back 403/404, never the object.

The unguessable Snowflake and the salted hashid lower the odds anyone reaches a foreign id; the tenant-scoped authorization check is what makes reaching it harmless. First wall reduces attempts, second wall is the one that must never be missing - exactly the "an id is never authorization" point from Part 1, made concrete for multitenancy.


Salt rotation - self-serve, with a history so old links still work

Salts get rotated: a leak, a policy, periodic hygiene. And here's a benefit that falls straight out of int8 + Snowflake: because the underlying id is a stable BIGINT, rotating the salt changes only the outward encoding, never the data. So rotation can be a tenant self-service action - a button in their admin panel. No engineering, no migration, no deploy: the tenant rotates their own salt and every new link they hand out uses it.

That makes leaked links revocable by the customer who owns them. But rotating naively would break every bookmark and emailed link at once - so I don't rotate destructively. I keep a salt history: current salt first, then every retired salt in order. On the way out, always encode with the current salt. On the way in, try current first; if it doesn't decode, try each historical salt until one does. Old links keep working; new links use the new salt.

Rotate anything, at any level - and always keep the history

The (tenant Γ— entity_type) matrix plus the tenant master salt is the whole point: everything is rotatable, at whatever granularity the situation needs.

  • The tenant master salt - a nuclear rotate: re-scopes every type at once (a suspected full-account leak).
  • One entity type's salt - just order links leaked in an export? Rotate the order cell for that tenant and nothing else moves; project and user links keep resolving.
  • A single object - narrow blast radius when exactly one link is known-bad.
TENANT  acme-co
  master salt  ───────────────────────────────────────────────  rotate ⟳  (re-scopes everything)
     β”‚
     β”œβ”€β”€ entity type: order    salt cell = [ current | v3 | v2 | v1 ]   rotate ⟳  (only order links move)
     β”œβ”€β”€ entity type: project  salt cell = [ current | v2 | v1 ]        rotate ⟳
     └── entity type: user     salt cell = [ current | v1 ]             rotate ⟳
                                             β”‚        └─────────── retired: kept for back-compat + stats
                                             └── outward encoding only β€” the BIGINT id never moves

  salts are a (tenant Γ— entity_type) matrix, each CELL keeps its own history.
  rotate any cell, or the master, or one object β€” always allowed, never touches data.
  per-cell strategy for retired salts:  accept+audit Β· grace window Β· hard cutoff
Enter fullscreen mode Exit fullscreen mode

Because the id underneath is a stable BIGINT, none of these touch data - it's always only the outward encoding. Rotate the master, rotate one type, rotate one thing: always allowed, always cheap.

And historicity is mandatory, not optional. Every retired salt is kept - at every level - for two reasons: old links keep resolving (backward compatibility), and the audit trail below turns each retired salt into statistics on who's still using stale links. Whether a given retired salt still decodes is a per-level strategy, and it's worth making explicit and configurable:

  • accept + audit - old salt still resolves, every hit logged (default; maximum backward-compat, full visibility);
  • grace window - old salt resolves for N days after rotation, then hard-stops;
  • hard cutoff - old salt rejected immediately (a real leak you want dead now), but still recorded so you can see the leaked link being hammered.

Different levels can run different strategies (master salt on hard cutoff, a noisy order type on grace window). The rule that never bends: a retired salt is never silently forgotten - it's kept, and its use is always recorded, if only for the stats.


A tenant by entity_type salt matrix: a tenant master salt over per-type cells, each cell holding its own history from current back to v1

The point: every retired salt becomes a tripwire

Here's the part that turns a boring rotation mechanism into a security signal. When an incoming link decodes only under an old, retired salt - never the current one - that's information. Someone is walking a link minted before the last rotation: a stale bookmark, an old email, or a link that leaked and is being replayed. Rotation without audit is blind. Rotation with audit turns each retired salt into a tripwire: the moment a stale or leaked link is used, you get an audit event with the tenant, the token, and how old the salt was. Self-serve rotation gives the tenant the revoke button; the audit tells them who's still knocking with the old key.

incoming public id   "kZ9mQ2xR"
        β”‚
        β–Ό
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚  gateway: decode hashid  β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        β”‚  try salts in order
        β–Ό
   current salt   ──decodes?──►  int64   β†’  normal request        (no event)
        β”‚ no
        β–Ό
   salt v(n-1)    ──decodes?──►  int64   β†’  serve  +  🚨 AUDIT: old-salt hit
        β”‚ no
        β–Ό
   salt v(n-2)    ──decodes?──►  int64   β†’  serve  +  🚨 AUDIT: old-salt hit
        β”‚ no
        β–Ό
   no salt matches  β†’  reject (404)  β€”  not a link we ever minted

  decoded ONLY under a retired salt  =  a stale / leaked / bookmarked
  link still in the wild.  Rotation without audit learns nothing.
Enter fullscreen mode Exit fullscreen mode
// The concrete cell that .Encoder()/.Decoder() hand back. It IS both interfaces
// for one (tenant Γ— type) pair: the encoder uses the current salt, the decoder
// walks the whole history. Nothing outside sees a salt - only Encode/Decode.
type typeCodec struct {
    tenant TenantID
    typ    EntityType
    salts  SaltHistory // newest-first: [current, v(n-1), v(n-2), ...]
    audit  AuditSink
    lib    HashidLib   // speps/go-hashids or sqids - the actual codec
}

func (c *typeCodec) Encoder() Encoder { return c }
func (c *typeCodec) Decoder() Decoder { return c }

// Encode always mints with the CURRENT salt.
func (c *typeCodec) Encode(ids ...ID) (string, error) {
    return c.lib.With(c.salts.Current()).Encode(ids)
}

// Decode walks this cell's history; a hit under a retired salt is a tripwire.
// The returned meta carries the SaltAge so the gateway applies its strategy.
func (c *typeCodec) Decode(token string) ([]ID, DecodeMeta, error) {
    for age, salt := range c.salts.All() { // 0 = current, then retired in order
        ids, err := c.lib.With(salt).Decode(token)
        if err != nil {
            continue // wrong salt, try the next in this cell's history
        }
        if age > 0 { // decoded under a RETIRED salt: a stale/leaked link is live
            c.audit.Emit(OldSaltHit{Tenant: c.tenant, Type: c.typ, Token: token, SaltAge: age})
        }
        return ids, DecodeMeta{SaltAge: age}, nil
    }
    return nil, DecodeMeta{}, ErrUnknownID
}
Enter fullscreen mode Exit fullscreen mode

Hashid decode chain: try current salt then each retired salt, emit an audit event on an old-salt hit

hashids is obfuscation, not encryption

I have to say this as loudly as the "id is not auth" line from Part 1, because they're the same mistake in a different hat. A hashid is obfuscation, not encryption. The salt is not a key; the encoding is reversible by anyone who knows the (public, off-the-shelf) algorithm and can guess the salt. Do not treat the opaque string as a secret. It buys you: no naked sequential ints in URLs, no trivial enumeration, no cross-tenant correlation, and - with salt history - a leak tripwire. It does not buy you authorization. Every request that arrives with a hashid still gets decoded to an int64 and then runs the exact same ownership check from Part 1. The opaque string and the authz check are partners, never substitutes.


The browser can't count that high (the castration bug)

This one bites everybody exactly once, and it's silent, which is the worst kind.

JavaScript's Number is an IEEE-754 float64. It represents integers exactly only up to 2⁡³ βˆ’ 1 = 9,007,199,254,740,991. A 64-bit Snowflake id is bigger than that. So the moment it goes through JSON.parse (or any Number() in JS), the browser silently rounds it to the nearest representable float. The last digits get mangled. I call it getting castrated - the id comes back looking almost right, wrong in the tail, and nothing throws.

JavaScript Number  =  IEEE-754 float64
exact integers only up to   2^53 - 1  =  9,007,199,254,740,991

Snowflake id:   7,239,344,029,703,798,784      (19 digits, well past 2^53)
                       β”‚
                       β”‚   JSON.parse(...)  /  Number(...)
                       β–Ό
browser sees:   7,239,344,029,703,799,000      ← the tail is rounded off

  same shape, DIFFERENT record.  No error.  No warning.
  It reads, edits, or deletes the wrong row - and the logs look fine.
Enter fullscreen mode Exit fullscreen mode

No exception, no NaN, no console warning. The id just quietly points at a different (or non-existent) record. You find out when a customer reports that saving one thing changed another, and you spend a day disbelieving your own database before you realise the browser did it in transit.

The fix is dull and absolute: serialize every int64 as a string in any web-facing JSON. Not "the big ones" - every int64, so there's no boundary to remember and no field that slips through. A string round-trips through JS untouched; you parse it back to an int64 on the server where 64 bits actually exist. Yes, even for the admin panel - the browser is a browser regardless of who's logged in.

{
  "order_id":  "7239344029703798784",
  "tenant_id": "7188990030102237184",
  "public_id": "kZ9mQ2xR",
  "total_cents": 4990
}
Enter fullscreen mode Exit fullscreen mode

The two 64-bit ids are strings, the small total_cents stays a real number (nowhere near the ceiling), and public_id is the hashid for URLs. This isn't a hack I invented - it's exactly why proto3's canonical JSON mapping encodes int64 as a string by default. The people who designed the wire format hit this wall first and baked the fix into the standard. Follow them.


A 64-bit Snowflake id silently rounded by a browser's float64 Number, and the string fix

It all happens at the gateway

None of this belongs in your domain code. If your business logic knows what a hashid is, or remembers to stringify ids before responding, the concern has leaked into the wrong layer and you'll get it wrong somewhere. All of it lives in one place: the gateway / edge.

The gateway has exactly these jobs, and the services behind it have none of them:

  • type prefix - split the prefix off every inbound public id and resolve it, through the tenant's configured map, to an entity type: which salt cell decodes it, which handler owns it. On the way out, prepend the current prefix for that type.
  • hashid codec - decode every inbound public id (single or composite) to int64(s), walking the salt history and emitting an audit event on an old-salt hit; encode every outbound int64 to the current salt's hashid.
  • int64 ⇆ string - stringify every int64 on the way out to the browser, parse it back on the way in.

Behind that seam, services speak pure int8, end to end. The proto fields are int64, the domain objects hold int64, the SQL columns are BIGINT. Domain code never hears the words "hashid" or "string id" - it can't get the encoding wrong because it never does the encoding.

LEVEL 1   browser / public API
          sees opaque clean hashid URL strings, and every int64 as a "string"
               β”‚  hashids in URLs, JSON with string-encoded ids
               β–Ό
LEVEL 2   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β”‚  GATEWAY  β€”  the only place any of this lives        β”‚
          β”‚    1. hashid codec: decode in / encode out           β”‚
          β”‚       + composite [master,child]  + salt history     β”‚
          β”‚       + audit on old-salt hits                       β”‚
          β”‚    2. int64  <->  string  in all web JSON            β”‚
          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
               β”‚  raw int64  (gRPC int64)
               β–Ό
LEVEL 3   services  β€”  speak pure int8 end to end
          domain code never hears "hashid" or "string id"
               β”‚  int64
               β–Ό
LEVEL 4   PostgreSQL  β€”  BIGINT primary keys, time-ordered inserts
Enter fullscreen mode Exit fullscreen mode

One boundary, a handful of jobs, and everything inside it stays honest by never being asked to lie about a number.


Four levels - browser, gateway, services, database - with the gateway owning hashid and int64-string translation

The prefix is routable: one door in front, two gateways behind

Here's the part that makes the prefix more than cosmetics. Because the type is readable before the decode, the prefix is a dispatch key. One gateway can stand in front of everything and send o… to the order upstream and c… to the comment upstream - no decode first, no lookup, no need for the URL path to carry the type at all.

The more useful half is one level further out. The same property lets you split the public and the internal surface at the very edge. A front proxy - nginx-level, or any edge router - looks at the first character of the id and sends prefixed public tokens to the public gateway, while internal int64 traffic stays on the internal one. The two surfaces never share a door, and the decision costs a string prefix match.

  PUBLIC traffic                                   INTERNAL traffic
  o kZ9mQ2xR   c 7bQ0pLMn                          order_id = 7239344029703798784
        β”‚           β”‚                                        β”‚
        β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜                                        β”‚
              β–Ό                                              β”‚
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                               β”‚
  β”‚  front proxy (edge)      β”‚   matches the PREFIX only     β”‚
  β”‚  o… c… p…  β†’  public     β”‚   no decode, no lookup, no db β”‚
  β”‚  unknown   β†’  public     β”‚   default route is the SAFE   β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   one, never the internal     β”‚
               β–Ό                                             β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  PUBLIC gateway          β”‚              β”‚  INTERNAL gateway        β”‚
  β”‚  prefix β†’ salt cell      β”‚              β”‚  raw int64, no codec     β”‚
  β”‚  decode Β· authz Β· audit  β”‚              β”‚  service + staff traffic β”‚
  β”‚  int64 ⇆ string          β”‚              β”‚  authz, same as ever     β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
               └────────►  services - pure int8  β—„β”€β”€β”€β”€β”€β”€β”€β”˜

  the prefix is a DISPATCH key. It is never the authorization decision.
Enter fullscreen mode Exit fullscreen mode

Now the honest caveats, because this is the kind of mechanism that quietly turns into a security assumption:

  • The prefix is not authentication. Anyone can type an o. It tells you what a token claims to be, and nothing whatsoever about who is holding it. The tenant-scoped ownership check runs unchanged behind every door.
  • Routing must not become the authorization decision. "It arrived at the public gateway, so it's a legitimate public request" is the same error as "it decoded, so it's allowed" - just moved into the proxy config where nobody reviews it. Routing picks a handler; the handler still asks whether this caller may touch this object.
  • A wrong-prefix token fails closed. If the prefix and the salt cell disagree, the answer is a flat rejection - not a fallback that tries the other cells until something decodes. A router that shops a token around has rebuilt enumeration with extra steps.
  • A routing rule built on a tenant-editable value needs a fallback. Prefixes are self-serve, so a mapping can change while a request is in flight and while a proxy's config is stale. Two things keep that from becoming an outage or, worse, a leak: keep the retired mapping resolvable (accept + audit / grace window / hard cutoff, exactly as with salts), and make the default route the safe one - an unknown prefix goes to the public gateway and is rejected there. It must never fall through to the internal side.

And the framing I'd want kept with it: there are many patterns for keeping a public gateway and an internal gateway safely apart - separate hostnames, separate networks, mutual TLS on the internal side, a header the edge strips and the inside trusts. This one is simply the pattern built on the id itself. If you already have prefixed public ids, it's nearly free. If you don't, "I want to route by prefix" is not on its own a good reason to introduce them. It's a capability to pick from, not a prescription.


One opaque token decoding into a designed tuple of tenant, resource type, resource id, expiry and nonce, each field labelled with its job

The real question: what's possible vs what's right for you

Here's the thing I want to be honest about after two parts of machinery: everything here is a capability, not a prescription. Composite tokens, self-describing one-time links, a per-(tenant Γ— type) salt matrix with per-cell rotation strategies, typed prefixes and prefix-based routing at the edge, strings-as-numbers - it all works, and it's all optional. The question that actually matters isn't "can I?" (you can) - it's "which of these fit me?", and only you can answer it. In a lot of cases there's more than one right way; you're balancing a few forces at once:

  • Simplicity. Fewer moving parts = fewer ways to be wrong at 3am. A single per-tenant salt with plain hashids may be everything you need; the full matrix with three retired-salt strategies is power you pay for in complexity forever.
  • Security. What does a leak, an enumeration, or a replay actually cost you? A public blog's ids and a health record's ids are different threat models. Don't buy a vault for a doormat - and don't put a doormat on a vault.
  • A new approach. A clever composite one-time token is elegant, but every non-standard mechanism is something the next engineer has to learn, trust, and not break. Novelty carries a tax; sometimes it's worth it, sometimes "boring and obvious" wins.
  • Support / operations. Salt histories, audit sinks, nonce stores, rotation UIs - that's all real code to run, monitor, migrate, and hand over. The most elegant design you can't operate is a liability, not an asset.

So I'm not telling you to ship all of this. I'm showing that it's possible and how, so you can pick the subset that matches your threat model and your team's capacity. Sane default: start simple - int8 keys, plain per-tenant hashids, and the ownership check - and add each layer (composite tokens, rotation, audit, one-time links) only when a real requirement pulls it in, never because it's clever. The one dial that isn't a dial - the wall you never remove - is the tenant-scoped authorization check. Everything else on this page is a trade-off you get to make on purpose.


The judgment is the human part

I lean hard on AI assistants for work like this, and my take doesn't move: AI amplifies a good engineer and exposes a weak one. Ask a model for a data model and it'll cheerfully hand you UUIDv4 keys everywhere, raw sequential ids in your URLs, and a hand-rolled generator it didn't need to write - fast, clean-looking, passes the demo. It won't, unprompted, tell you to use int8 + Snowflake for locality, that the raw ints must stop at the trust boundary, that one composite token can carry the whole ownership chain, that a retired salt is a free tripwire, or that the browser is about to castrate every id over 2⁡³ and corrupt the wrong record in silence.

The mechanics are cheap and mostly already written - Snowflake and hashids libraries exist; you configure them, you don't author them. The judgment - int8 + Snowflake for the key, raw ints stop at the wall, composite tokens for ownership, self-serve rotation with an audit tripwire, every int64 a string at the browser - that's the part the human owns. Point the multiplier at a good design and it ships fast. Point it at "just give me some ids" and it'll help you build the leak faster than you can schedule the incident review.

If you build serious backends, this is the kind of decision I keep writing up. Follow along on github.com/brilliant-almazov. And if your URLs have raw sequential ids in them right now, or your API sends int64s as numbers: which one is going to page you first? I'd genuinely like to compare notes.

Top comments (0)