Skip to content

ScopeMask

Scope-bound opaque id masking.

ScopeMask converts internal identifiers: database keys, emails, UUIDs, etc into short, opaque strings that are safe to expose in URLs and APIs, and decodes them back to the original value on demand.

Each id is bound to a scope (such as user or order) and a secret. The same value yields a different id in every scope, and every id carries a keyed integrity check: a modified id, the wrong scope, or the wrong secret is rejected rather than silently decoding to an incorrect value.

Overview

  • Per-scope alphabet. The secret and scope derive a unique character alphabet, so ids from different scopes are unrelated and decoding under the wrong scope fails.
  • Integrity. A short keyed checksum (HMAC) is embedded in every id, so tampering or a wrong secret is detected on decode.
  • Prefixes. An optional prefix produces readable identifiers such as usr_....
  • Minimum length. Ids are padded to at least 16 characters by default.
  • Secret rotation. Retired secrets can still be accepted for decoding.

Payload layout

Before encoding, the value is wrapped into a byte payload:

            payload bytes
┌─────────┬─────────┬───────────┬───────────────┐
│ VERSION │   TAG   │   body    │   MAC (4 B)   │
│  1 byte │ 1 byte  │  n bytes  │  HMAC[:4]     │
└─────────┴─────────┴───────────┴───────────────┘
 └──────── signed ────────┘
                                       └ keyed checksum over (scope + signed)
TAG: 0=int  1=string  2=bytes  3=uuid

Encoding

flowchart LR
  V["value"] --> P["to payload<br/>TAG + body"]
  P --> S["prepend VERSION<br/>= signed"]
  K(["secret + scope"]) --> M["HMAC-SHA256, first 4 bytes<br/>= MAC"]
  S --> M
  M --> PL["payload<br/>VERSION + TAG + body + MAC"]
  PL --> N["split into chunks<br/>= numbers"]
  K --> A["derive alphabet"]
  A --> E["sqids encode"]
  N --> E
  E --> PR["prepend prefix"]
  PR --> ID[["opaque id"]]

Encode

Wrap the value as VERSION + TAG + body (the signed bytes); the TAG records the type and the body is the raw bytes. The first 4 bytes of HMAC-SHA256(secret, scope + signed) is the MAC and appended to form the payload. Then splits the payload into 6-byte chunks and encode them with sqids using the alphabet derived from the secret and scope, then prepend the prefix.

Decoding

flowchart LR
  ID[["opaque id"]] --> SP["strip prefix"]
  K(["secret + scope"]) --> A["derive alphabet"]
  SP --> D["sqids decode<br/>= numbers"]
  A --> D
  D --> RC{"re-encode<br/>matches?"}
  RC -- no --> X["reject<br/>try next secret"]
  RC -- yes --> PL["join chunks<br/>= payload"]
  PL --> SV["split<br/>signed | MAC"]
  SV --> VV{"VERSION ok?"}
  VV -- no --> X
  K --> MC{"MAC matches?"}
  VV -- yes --> MC
  MC -- no --> X
  MC -- yes --> FP["from payload<br/>by TAG"]
  FP --> VAL(["value"])

Decode

Strip the prefix and sqids-decode with the secret-and-scope alphabet; reject if re-encoding the numbers does not reproduce the input. Then splits off the MAC, checks the version, and recomputes the MAC, comparing in constant time; rejects on any mismatch. Then reads the TAG to restore the original value and its type. If previous secrets are configured, each is tried until one verifies.

Important Note

  • ScopeMask is not an encryption library. The 4-byte MAC is an integrity check sized for URLs or API Data, not a 256-bit auth tag. For sensitive data, use proper encryption libraries and protocols.

  • It is not a replacement for authentication or authorization. Decoding a valid user ID only tells that the ID is real and untampered. It does not tell, who is asking or whether they are allowed to see that record. Always keep authentication in place, and always run permission checks on every request. Verify that the caller actually owns the data before returning it, and never serve a record to someone who does not own it. ScopeMask stops the ID from leaking and from being guessed or forged.

Get started

Pick a language:

Built on sqids for the short, reversible encoding.