DEV Community

Anton Brilliantov
Anton Brilliantov

Posted on

Snowflake IDs - Part 3: A Typed Prefix, and Two Doors

Part 1 settled the primary key. Part 2 got it across the wire, the URL and the browser. This part is about the thing that fell out of both and turned out to be the most useful of the three: the id is a seam. Inside the mesh it's a raw int64; on the public surface it's an opaque token. That difference isn't cosmetic - it's a line between two surfaces with different threat models, and the id is the cheapest place in the whole system to draw it. Here's what a typed prefix on the public token buys you: routing before decoding, a public surface that says nothing about your internals, and - if you want it - two front doors that never share an entry point.


πŸ‘‹ 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; Part 2 took that id out of the building - hashids at the edge, composite tokens, tenant-controlled salt rotation, and every int64 as a string before it touches a browser. This part is the structural consequence of Part 2, and it's the one I actually think about most: once the public id and the internal id are different values, you have a seam, and a seam is something you can build on. Running notes: github.com/brilliant-almazov.

As always: these are my habits on one codebase, with the trade-offs I've actually paid. Not advice for yours.


The seam nobody planned

Part 2 ended with a boundary drawn for defensive reasons: raw ints don't leave the building, so the gateway encodes on the way out and decodes on the way in. That was framed as a chore - a translation layer you maintain because browsers and attackers exist.

Look at it again and it's not a chore. It's the only place in the system where every request has to stop and be interpreted. Every public request carries an id. Every id has to be translated. So the encoding of the id is a checkpoint that nothing can route around - not a new endpoint someone added in a hurry, not a webhook, not a legacy path.

Two surfaces separated by the identifier: the public side carries an opaque token, the internal side carries a raw int64

That's worth saying precisely, because "public API" and "internal API" get used as if they were two halves of one thing:

Public surface Internal surface
Callers browsers, third parties, anyone with the URL services you deploy
Identifier opaque token raw int64
Threat model hostile by default trusted, network-isolated
Shape leakage must reveal nothing reveals everything, that's the point
Contract stability you own it forever you can change it on Tuesday

Two surfaces, two threat models, two lifetimes. They are not tiers of the same API. And the identifier is already different between them - which means the identifier is already the marker of which surface you're on. A raw int64 arriving on the public surface is a bug by definition. An opaque token arriving on the internal surface is a bug by definition. You get that check for free, forever, without writing it.

The rest of this article is what you can build once you accept that seam as a first-class thing rather than a translation chore.


Give the public token a type

Here's the change, and it's small: the public id is not just a token, it's a prefix plus a token.

  o_kZ9mQ2xR            an order
  c_7bQ0pLMn            a comment
  iv_9Kd2mQ7rX4         an invoice
  l_3nR8tW1yA6          a one-time link

  <prefix><sep><token>
      β”‚       β”‚     β”” the hashid from Part 2 β€” opaque, salted, tenant-scoped
      β”‚       β”” a character that cannot appear in the token alphabet
      β”” the entity TYPE, in the clear
Enter fullscreen mode Exit fullscreen mode

The token half is exactly Part 2: a salted hashid of one id or of a designed tuple. The prefix half is new, it's in the clear, and it's readable before you decode anything.

Anatomy of a public id: a type prefix in the clear, a separator, and the opaque salted token from Part 2

The separator is not a detail

The first thing that bites: if your prefix is o and your token alphabet contains o, you cannot parse okZ9mQ2xR back into a prefix and a token. There are exactly two honest fixes and you have to pick one on day one, because changing it later invalidates every link in the wild:

  • A separator character - o_kZ9mQ2xR. Pick a character excluded from the hashid alphabet (_ is the usual one, since the default alphabets are alphanumeric). Costs one character, reads well, survives copy-paste and double-click selection in most terminals.
  • A fixed-width prefix plus a restricted alphabet - prefixes are always exactly two characters, and those characters are removed from the token alphabet. No separator, slightly shorter, but you've now got a rule two layers deep that a future engineer has to not break.

I use the separator. It's more legible in a URL, it's obvious what it is when a customer pastes one into a support ticket, and the "why is the alphabet weird" question never comes up.

What the prefix actually buys

The prefix means the gateway knows what kind of thing the token points at before it does any work. Three things follow from that, and they compound:

  1. Which salt cell decodes it. Part 2's salt matrix is (tenant Γ— entity_type). Without the prefix, the entity type has to come from the route - which is fine when the route is /api/v1/orders/{id}, and useless when the route is /api/v1/l/{token} or a generic /api/v1/resolve/{id}. The prefix carries the type with the value instead of with the path.
  2. Which handler owns it. Type-first dispatch instead of path-first dispatch. More on this below - it's the part that makes a gateway simple instead of a router with a switch statement in it.
  3. Cross-type replay stops being possible. A token minted for an order won't decode under the comment salt - that was already true in Part 2. What the prefix adds is that the mismatch is caught before the decode, and caught explicitly: the route says "this position is an order", the prefix says c, and the request dies at the door with no decode attempted, no salt-history walk, no audit noise. You've turned a silent decode failure into a stated contract violation.

That third point is worth dwelling on. Without a prefix, "wrong type" and "wrong tenant" and "garbage string" all produce the same thing: a decode that doesn't resolve. You get one undifferentiated 404. With a prefix, you can tell a malformed id from a wrong-type id from an unknown-prefix id, and those are three different signals. The first is a broken client, the second is either a bug or someone probing, the third is either a stale link or someone guessing at your type namespace. Same 404 to the caller, three different lines in your logs.


The prefix map is configuration, not code

This is the part that surprised me, and it's a direct inheritance from Part 2's salt rotation.

The prefix isn't a constant in the codebase. It's a row in a per-tenant map, editable from the admin panel, exactly like the salt:

Public prefix Internal type Salt cell Owning upstream
o order (tenant Γ— order) order service
c comment (tenant Γ— comment) comment service
iv invoice (tenant Γ— invoice) billing service
l link (tenant Γ— link) the gateway itself

Change o to ord and the outward encoding changes. Nothing else moves. The BIGINT in the database is untouched, the internal type name is untouched, the gRPC field is untouched, the index is untouched. The same property that made salt rotation a customer-facing button makes the prefix map a customer-facing setting: because the stored id is a stable BIGINT, everything on the outside of the seam is presentation.

The tenant-editable prefix map: a public alias resolving to an internal type, a salt cell and an owning upstream

Why would a tenant care? Mostly they don't, and the defaults ship as defaults. But two real reasons come up:

  • Their public API is their product. If a tenant exposes our objects through their own API to their own customers, the token is in their documentation. ord_... versus o_... is their naming decision, not mine, and it costs me a config row to let them have it.
  • The prefix is a deliberate alias, so it can be deliberately meaningless. Which brings us to the next section.

An anonymous public surface

The public API should say nothing about how the system is built. Not the numeric id, not the table name, not the internal route, not the service that owns it, not the number of services. That's not paranoia; it's just the honest consequence of the two-surface table above. The internal shape changes when I split a service or merge two; the public surface must not change when that happens, and it can't change if it never described the internal shape in the first place.

The prefix is a chosen public alias for an internal type. It does not have to resemble the internal name at all:

  PUBLIC (chosen alias)          INTERNAL (real shape)

    o_kZ9mQ2xR          β†’        order            β†’ order service      β†’ orders table
    c_7bQ0pLMn          β†’        comment          β†’ comment service    β†’ comments table
    x7_9Kd2mQ7rX4       β†’        billing_document β†’ billing service    β†’ invoices table
                β”‚
                β”” says nothing about "invoice", "billing", or which service answers
Enter fullscreen mode Exit fullscreen mode

Three levels of naming, and only the first one is a promise to the outside world. The internal type can be renamed, the service can be split in two, the table can be partitioned into six - the public alias x7 is stable through all of it, because it was never derived from any of them.

The dull version of this rule, which is where most leaks actually live:

  • No numeric id anywhere public. Covered in Part 2, still the main one.
  • No internal type name in the public alias unless you've decided it's fine forever - because once it's in a customer's stored links, it is forever.
  • No internal route shape. The public path is /api/v1/orders/{id}, not /order-service/v2/orders/{id}. If your public paths mirror your service boundaries, every service split becomes a public API break.
  • No shape in the errors. A constraint name, a table name, or a driver message in a 400 body undoes all of the above in one line. This one gets me more often than the ids do, because it arrives through a generic error handler nobody looked at.

Routing on the prefix

Because the type is readable before the decode, the gateway can dispatch on it. That's the difference between a gateway that is a table and a gateway that is a switch statement growing one arm per feature.

// A public id is <prefix><sep><token>. Parsing is dumb on purpose: it splits,
// it does not interpret. Interpretation needs the tenant's map, which is below.
type PublicID struct {
    Prefix string
    Token  string
}

// The prefix map is per tenant and editable, exactly like the salt history.
// Current() is what we mint with; Retired() is the remap grace window (see below).
type Prefixes  interface{ For(tenant TenantID) PrefixMap }
type PrefixMap interface {
    Current(prefix string) (EntityType, bool) // inbound: prefix -> type
    Retired(prefix string) (EntityType, bool) // inbound: a prefix we recently moved off
    Alias(typ EntityType) (string, bool)      // outbound: type -> prefix, for minting
}

// Routes maps an internal TYPE to the upstream that owns it. Note what is NOT
// here: no path parsing, no per-entity handler registration, no switch.
type Routes interface{ Upstream(typ EntityType) (Upstream, bool) }
Enter fullscreen mode Exit fullscreen mode

Resolution is then one straight line with no branching on entity type anywhere:

// Resolve turns a public id into an internal reference. It answers exactly two
// questions - which type, which ids - and refuses everything it cannot answer.
// It deliberately does NOT answer "is the caller allowed to", see the next section.
func (g *Gateway) Resolve(tenant TenantID, want EntityType, raw string) (Ref, error) {
    pid, err := ParsePublicID(raw) // split on the separator, nothing more
    if err != nil {
        return Ref{}, ErrUnknownID // malformed: not a shape we ever mint
    }

    m := g.prefixes.For(tenant)
    typ, ok := m.Current(pid.Prefix)
    if !ok {
        typ, ok = m.Retired(pid.Prefix)
        if !ok {
            return Ref{}, ErrUnknownID // unknown prefix: fail closed, always
        }
        g.audit.Emit(RetiredPrefixHit{Tenant: tenant, Prefix: pid.Prefix})
    }

    // The route declares what it expects; the token declares what it is.
    // A mismatch is a contract violation, not a lookup miss - and it costs no decode.
    if want != TypeAny && typ != want {
        return Ref{}, ErrWrongType
    }

    // From here it is Part 2 unchanged: the (tenant Γ— type) salt cell decodes it,
    // walking the salt history and firing the old-salt tripwire on a retired hit.
    ids, meta, err := g.codecs.For(tenant).Type(typ).Decoder().Decode(pid.Token)
    if err != nil {
        return Ref{}, ErrUnknownID
    }
    return Ref{Tenant: tenant, Type: typ, IDs: ids, SaltAge: meta.SaltAge}, nil
}

// Dispatch is a lookup, not a decision. Adding an entity type adds a row to the
// routing table and touches no code in the gateway.
func (g *Gateway) Dispatch(ref Ref) (Upstream, error) {
    up, ok := g.routes.Upstream(ref.Type)
    if !ok {
        return nil, ErrNoRoute
    }
    return up, nil
}
Enter fullscreen mode Exit fullscreen mode

Two things I want to point at in that code, because they're the whole reason I like this shape:

  • Resolve has no knowledge of any specific entity. No case TypeOrder:. Adding invoices is a config row and an upstream registration. The gateway does not grow.
  • want comes from the route, not from the token. The token never gets to tell the gateway what it is on a typed route. /api/v1/orders/{id} passes TypeOrder and a comment token dies there. Only a deliberately generic route - a share link resolver, say - passes TypeAny, and that route is exactly the one where the prefix is doing real work, because nothing else knows the type.

Two doors

Here's the deployment consequence, and it's optional in a way the rest of this isn't.

If the public surface and the internal surface are genuinely different surfaces, they can be different front doors - a public gateway and an internal gateway, sitting behind an edge router that decides which door a request is even allowed to knock on. Public tokens and raw int64 traffic then never share an entry point.

An edge router with two listeners: a public door that accepts only prefixed tokens and an internal door restricted to the mesh

Shaped as an edge-router config, it's less clever than it sounds:

# ---- public door: opaque tokens only, open to the internet -------------------
server {
    listen 443 ssl;
    server_name api.example.com;

    # A public id is <prefix>_<token>. If it doesn't have that shape, it isn't
    # a public id, and the request never reaches an application.
    location ~ "^/api/v1/[a-z-]+/(?<prefix>[a-z]{1,3})_(?<token>[0-9A-Za-z]{6,40})$" {
        proxy_pass http://public_gateway;
    }
    location ~ "^/api/v1/" {
        proxy_pass http://public_gateway;   # collection routes: no id in the path
    }
    location / { return 404; }
}

# ---- internal door: raw int64 traffic, not reachable from outside ------------
server {
    listen 8443 ssl;
    server_name gateway.internal;

    allow 10.0.0.0/8;                       # the mesh, and nothing else
    deny  all;

    location / { proxy_pass http://internal_gateway; }
}
Enter fullscreen mode Exit fullscreen mode

What that buys, concretely: a raw int64 in a path can never reach the public gateway, because the public listener has no location that matches a bare number. Not "the gateway rejects it" - the request never becomes a request. That's a different quality of guarantee than an application-level check, and it survives an application-level bug.

The thing to be careful about here

Notice what the config above does not do: it does not know your prefix map. It matches the shape [a-z]{1,3}_<token>, not the set {o, c, iv, l}.

That's on purpose, and it's the single most important line in this article's honest column. The prefix map is tenant-editable. An edge-router config is deployed. The moment you key a deployed routing rule on a value a tenant can change from an admin panel, you have built a system where a customer action can break routing, and the failure will happen at the layer with the worst observability and the slowest rollback.

So: the edge router separates doors by shape. The gateway dispatches by prefix, because the gateway is the thing that can read the map. Coarse where it's static, fine where it's dynamic. Cross that line and the tenant-editable value becomes a deploy dependency.


The honest part

Everything above is mechanism. Here's what it isn't, in the same blunt register as "a hashid is not encryption" from Part 2.

A prefix is not authentication, and routing is not authorization

The prefix tells you what kind of thing a token claims to be. That is all it tells you. It is in the clear, it's guessable in about four attempts, and anyone can put o_ in front of a string.

So the rule from Part 2 does not move an inch: after the decode, every request runs the tenant-scoped ownership check. Does this authenticated caller have the right to this object? Not "does the row exist" - the dangerous case is exactly the one where the row does exist and belongs to somebody else. Routing a request to the correct upstream is not a statement about who's allowed to be there; it's a statement about who should be asked.

Layer What it does What it does NOT do
Edge router keeps public and internal traffic on separate listeners authenticate anybody
Prefix says which type, which salt cell, which upstream prove the token is real
Hashid decode turns a valid token into int64(s) prove the caller may see them
Ownership check decides β€”

Only the last row is a security control. Everything above it reduces the number of ways to reach the check, and none of it replaces the check.

Fail closed, always

A prefix that isn't in the map - unknown, retired past its window, or made up - must be rejected. Not "guessed at", not "tried against every salt cell until one decodes". That fallback sounds helpful and is precisely a type-confusion oracle: try x_<token> against every type and one of them eventually resolves, and now the attacker knows what the token is.

The resolution path for an incoming public id, with every rejection failing closed rather than probing other types

incoming public id      "o_kZ9mQ2xR"
        β”‚
        β–Ό  split on the separator
   malformed? ────────────────────────────► 404   (never a shape we mint)
        β”‚ no
        β–Ό  look up the prefix in THIS tenant's map
   current prefix?  ──yes──► type
        β”‚ no
        β–Ό
   retired prefix?  ──yes──► type  +  🚨 AUDIT: retired-prefix hit
        β”‚ no
        β–Ό
   unknown          ────────► 404   (fail closed β€” never probe other types)
        β”‚
        β–Ό  route says TypeOrder, token says comment?
   type mismatch    ────────► 404   (contract violation, no decode attempted)
        β”‚
        β–Ό  decode under the (tenant Γ— type) salt cell  β†’ Part 2, unchanged
        β–Ό  tenant-scoped ownership check               β†’ Part 1, unchanged
        β–Ό  serve
Enter fullscreen mode Exit fullscreen mode

A tenant-editable routing key needs a window

If a tenant renames o to ord on Tuesday, links minted on Monday still say o. Same problem as salt rotation, same solution, and it has to be built at the same time as the feature - not after the first support ticket:

  • the map keeps retired prefixes with the type they used to mean, exactly like the salt history keeps retired salts;
  • inbound resolution tries current, then retired;
  • outbound minting always uses the current alias;
  • a hit on a retired prefix is audited, which - same as the old-salt tripwire in Part 2 - tells you how much of the wild is still on the old naming, and therefore when it's safe to actually drop it.

If you skip the retired map, a prefix rename is a hard break of every link in every email your customer ever sent. That is not a rotation, it's an outage with a settings page in front of it.

Two gateways cost two of everything

This is the trade-off I want to state plainly, because "separate the surfaces" reads like an unambiguous win and it is not.

One gateway, strict internal split Two gateways behind an edge router
Deploy paths one two, and they can skew
Configs one two, and they drift
The public/internal boundary enforced by code and review enforced by network topology
A bug that leaks internal shape can reach the public surface cannot cross the listener
Cost of adding an endpoint one place decide which door, then one place
Failure mode a missing check a mismatched pair of configs

Two doors buys you a structural guarantee instead of a procedural one, and structural guarantees survive bad days. It costs you a second deploy path, a second config, and one more place for drift - and drift between two gateways is a genuinely unpleasant class of bug, because each half looks correct on its own.

One gateway with a strict public/internal split inside it is a legitimate choice, and often the better one. If your team is small, if your public surface is narrow, if the internal surface is only ever reached from inside the mesh anyway - one gateway with the two surfaces as separate route trees, separate middleware chains and separate listeners in the same process gets you most of it for a fraction of the operational weight. The seam is in the id either way. Where you put the wall around the seam is a capacity question, not a correctness question.


What this is: capabilities, not a prescription

Three parts in, the pattern of this series is probably clear: I keep showing machinery and then telling you not to ship all of it. That's deliberate, and it applies here more than anywhere, because everything in this part is optional in a way Parts 1 and 2 weren't.

  • Typed prefix. Buys type-first dispatch, cheap wrong-type rejection, and generic resolver routes. Costs a parsing rule you can never change and one more concept for the next engineer. Worth it the moment you have more than one entity type on a shared public surface, or a single generic link-resolver route.
  • Prefixes as tenant configuration. Buys tenants control of their own public naming. Costs a retired-prefix map, a rotation UI, and a rule about never keying deployed infrastructure on it. Only worth it if a tenant's public API is genuinely their product.
  • An anonymous public surface. Buys freedom to reshape the internals without breaking anyone. Costs nothing but discipline, and I'd argue it's the one item on this list that's close to unconditional - it's cheap in advance and impossible to retrofit once customers have your internal names in their code.
  • Two doors. Buys a structural boundary. Costs two deploy paths, two configs, and a drift surface. Worth it when the cost of a leaked internal surface is high enough to justify running two things.

There are plenty of ways to keep a public and an internal surface apart - separate deployments, mutual TLS on the internal side, an API-management layer in front, gRPC internally and REST externally with no shared handler code at all. This is the one built on the identifier, and its whole argument is that the identifier already crosses the boundary on every request, so the boundary might as well be enforced there.

The wall that isn't a dial, and never becomes one: the tenant-scoped ownership check on every request. Everything else on this page is a trade-off you get to make on purpose.


The judgment is still the human part

Same note I closed Part 2 on, because three parts of machinery haven't changed it. Ask an assistant to design a public API and you'll get sensible-looking routes with numeric ids in them, internal type names in the paths, one gateway serving both surfaces, and error bodies that name your database constraints. Fast, clean, demos beautifully. It won't tell you, unprompted, that the identifier is the seam, that a prefix makes the token self-describing before the decode, that keying an edge-router config on a tenant-editable value builds a customer-triggered outage, or that an unknown prefix must fail closed rather than politely try every salt cell.

The mechanics are cheap. The judgment about which of these to actually run - and what each one costs you at 3am - is the part that's still yours.

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 public API and your internal API are the same API right now: what would it cost you to change the shape of one service? I'd genuinely like to compare notes.

Top comments (0)