Skip to content
ADR-00526 Jul 2026
ADR-005acceptedTicket Integrity

Signing tickets with a key I chose to store in Postgres instead of KMS

A ticket is a plain payload plus an HMAC signature, and the obvious place to keep the signing key is a secrets manager. I put it in a database row instead, because the same key eventually has to be provisioned onto a scanner device, and a wrapped KMS key cannot be handed to a phone. That trade is real, I flagged it in the code the day I made it, and it is still open.

  • TypeScript
  • Prisma
  • PostgreSQL
  • HMAC-SHA256
  • Firebase Cloud Functions
01Problem & context

What forced a decision

Octo mints QR-coded tickets and needs a scanner to be able to verify one with no network available, which rules out a server-side verification call at the gate. The payload therefore has to carry its own proof, and a per-event HMAC secret is the mechanism. The question this record is about is not the signing scheme — that part is uncontroversial — it is where the fifty or so bytes of secret key actually live, given that a scanning device will eventually need the raw value, not a reference to it.

Hard constraints

  • A scanner must be able to verify a ticket's signature without a network call.
  • The same secret that signs a ticket must be provisionable to an authorized scanner device.
  • The secret must never appear in logs, error messages, or a stack trace.
  • A ticket's QR content must be exactly regenerable later from stored data, not approximated.
02Options evaluated

What was on the table

Each option carries the one sentence that justifies its verdict.

  1. Option 01rejected

    A managed secrets service (KMS)

    Generate and store each event's signing key in a cloud KMS, and sign/verify through it.

    For

    • Key material never exists outside a hardened service.
    • Rotation, audit logging and access policy come largely for free.
    • The default a security review will expect to see.

    Against

    • No path to getting the raw key onto a scanner for offline verification.
    • Adds a network dependency to key provisioning that the rest of the design exists to avoid.

    BecauseKMS is designed to keep the raw key material inside the service and hand back only wrapped operations. That is exactly the property that makes it wrong here: a scanner has to hold the raw secret on the device to verify offline, and a service built never to release its key cannot provision one to a phone.

  2. Option 02rejected

    One global signing key for the whole platform

    A single HMAC secret shared across every event, provisioned once.

    For

    • One key to manage, generate, and provision.
    • Simpler mental model with nothing per-event to look up.

    Against

    • A single leak compromises the entire platform, not one event.
    • A scanner authorized for one event can forge tickets for all of them.

    BecauseA key compromised anywhere compromises every event that has ever used it, and a scanner provisioned for one event could forge tickets for every other event on the platform. Scoping the blast radius to a single event was worth the extra row per event.

  3. Option 03chosen

    A per-event key stored as a Postgres row

    Mint a random 32-byte secret per event on first use, store it in a dedicated table, and hand the raw value to authorized scanners during provisioning.

    For

    • Compromise is scoped to one event rather than the whole platform.
    • The raw secret can be provisioned to a device, which the offline requirement demands.
    • A concurrent first-mint race is handled: a losing writer reloads the winner's key rather than creating two.

    Against

    • The key sits in the same database as everything else, protected by ordinary access control rather than a hardened secrets boundary.
    • There is no rotation path — signing a key change would invalidate every ticket already issued for that event.
    • Provisioning hands an authorized device the raw secret rather than a wrapped one, which is a wider blast radius than a KMS-backed design would allow.

    BecauseThis was the only option that could actually satisfy the offline constraint. A scanner needs the raw secret, so somewhere in the system the raw secret has to be readable by something that can hand it to a device — and once that is true, a KMS wrapper adds ceremony without adding protection against the risk that actually matters, which is the key being extracted from an authorized caller.

03Trade-off matrix

What each option costs

Scored on the axes that mattered here. The option I chose is weaker than the alternatives on at least one row, which is usually where the interesting conversation starts.

Trade-off matrix comparing 3 options across 4 criteria
CriterionA managed secrets service (KMS)One global signing key for the whole platformA per-event key stored as a Postgres rowChosen
Compromise blast radiusStrongWeakAdequate
Supports offline, on-device verificationThis is the requirement that eliminated KMS outright, regardless of its other strengths.WeakStrongStrong
Operational simplicityWeakStrongAdequate
Key rotation without invalidating issued ticketsThis is the axis the chosen option gives up. There is no rotation path today; rotating a key would invalidate every ticket already signed under it.StrongWeakWeak
04Decision

What I chose

Each event gets a 32-byte random secret, base64-encoded and stored in a dedicated table keyed by event id, minted lazily on first use with a reload-on-conflict path for the case where two requests race to mint the same event's key. A ticket's QR content is the payload — ticket id, tier id, event id, and the exact issued-at timestamp — followed by the HMAC-SHA256 signature of that payload under the event's secret. Verification uses a constant-time comparison. The issued-at timestamp is stored as its own column specifically so the payload can be regenerated later; the row's own creation timestamp is a different clock and is never substituted for it. The choice of Postgres over a managed secrets service is recorded in the code itself as a decision flagged for review, not a default I reached for without thinking about it.

A per-event key stored as a Postgres row

Reversibility

Cost to undo

Moving the key into a wrapped-export KMS scheme later is possible without reissuing tickets, because verification only needs the raw secret at the moment a scanner checks a signature — the storage location can change underneath that contract. What cannot be recovered after the fact is any ticket signed under a key that is later rotated or revoked; there is no versioning in the payload to let two keys be valid at once, so a rotation invalidates every unscanned ticket for that event. That is the actual cost of not building rotation now, and it is why I would treat this as revisitable but not free to revisit.

05Failure modes

What happens when it breaks

The ways this design can break, and what it is set up to do when they happen.

  • A tampered or bit-flipped QR payload is scanned

    minor
    Blast radius
    None. The signature check fails and the scan is rejected.
    Detection
    Constant-time HMAC comparison against the recomputed signature.
    Mitigation
    Rejected before any ticket lookup or database write occurs.
  • Two requests attempt to mint the same event''s key at the same time

    minor
    Blast radius
    Would otherwise create two different keys for one event.
    Detection
    The insert conflicts on the event's unique key constraint.
    Mitigation
    The losing writer reloads and uses the key the winner actually persisted.
  • The manifest endpoint that provisions scanners is called by an unauthorized party

    major
    Blast radius
    The raw signing secret for that event is disclosed, which is enough to forge valid tickets for it.
    Detection
    Authorization on the provisioning path is tighter than the platform''s usual any-membership check, specifically because it releases raw key material rather than aggregate data.
    Mitigation
    Narrower authorization than the surrounding endpoints, reviewed as part of this decision rather than inherited from the platform default. A wrapped or expiring provisioned value would narrow it further; that is the residual risk the chosen design accepts.
  • A signing key needs to be rotated after suspected compromise

    major
    Blast radius
    Every ticket already issued and not yet scanned for that event becomes unverifiable.
    Detection
    None automated; this would be a manual incident response.
    Mitigation
    None exists today. This is the most significant gap in the design and the direct cost of not building key versioning into the payload up front.
06Numbers

What it costs to run

Each figure says where it came from, so you can judge how much weight to give it.

32 bytes
Secret length

Generated with a cryptographically secure random source at first mint.

constant-time
Signature comparison

Verification uses a timing-safe comparison rather than a standard equality check, so a failed verification does not leak information about how much of the signature matched.

07Notes

The longer version

Why this record exists

Most write-ups of a signing scheme stop at the cryptography, because the cryptography is the easy and uncontroversial part. The decision that actually required judgement here was where the key lives, and it is a decision I made knowingly against the default a security review would expect.

I want to be direct about that rather than let the record imply I simply didn't think of KMS. I did, and I wrote down in the code, on the day, exactly why I was not using it: the offline requirement means a scanner has to hold the raw secret, and a service whose entire design goal is to never release raw key material cannot provision one to a phone. Once that constraint is on the table, KMS stops being the safer default and becomes the option that cannot satisfy the requirement at all.

What the trade actually costs

Putting the key in Postgres does not make the risk disappear, it relocates it. The risk moves from "can this service ever be tricked into releasing the key" to "is provisioning authorized tightly enough." That authorization was tightened beyond the platform's normal pattern for exactly this reason, and the code says so.

The harder question is what happens after a compromise rather than before one, and that is where this design is thinnest. Rotation is the axis it gives up: recovering from a suspected leak is an operational response today rather than an engineered one. I would rather record that plainly than imply the design has an answer it does not.

What I would do differently

I would add a key version to the signed payload before this goes further, even though it makes the QR content marginally larger. It is the one piece of this design that has no answer right now, and it is the piece most likely to matter if this were ever running at a scale where "just reissue everyone's ticket" stops being an acceptable incident response.

ADR-005Status — accepted