Reprinting Badges Without Duplicating Access Credentials
Symptom Statement Link to this section
An attendee loses their badge, walks to the help desk, and staff reprint it in ten seconds. The problem surfaces an hour later: the original badge still scans. Now one registration carries two live credentials, so the attendee can hand the old badge to a colleague and both walk through the access gate, or a lost badge picked up off the floor grants a stranger entry to a paid session. This page addresses that exact failure — a reprint that mints a second scannable credential instead of replacing the first — at the badge-reprint boundary. It is the credential-integrity half of the badge reprint handling stage, which owns how a physical badge is re-issued without weakening the access model behind it. The tells are consistent: two distinct barcode or QR serials resolving to one attendee_id, access-log entries showing the same person entering a gated area from two credentials seconds apart, a scanner admitting a badge that help-desk records mark as “reprinted,” and a post-event audit where badge scans exceed unique admissions.
Root Cause Analysis Link to this section
Four independent failures let a reprint create a second live credential. An incident is usually one of them, occasionally two stacked, so name which before you touch the reprint path.
- Reprint mints a new credential id instead of superseding. The reprint flow calls the same “create credential” routine as first issuance, generating a fresh, unrelated serial that is added alongside the original rather than replacing it. The access model now holds two independently-valid credentials for one registration, and nothing links them as old-and-new.
- Old badge not revoked. Even when the reprint knows the prior serial, it never writes it to a revocation set the scanner consults. The old badge remains cryptographically valid and version-current, so it passes every gate check exactly as it did before it was reported lost.
- No credential version or serial. The credential encodes only the
attendee_id, with no per-issuance serial or version counter. The scanner literally cannot tell an original from a reprint — both decode to the same identity — so there is no field on which to base a supersede-or-reject decision. - Race between reprint and original scan. The reprint transaction and a live scan of the old badge interleave. The mint-new and revoke-old steps are not atomic, so a scan lands in the window after the new serial exists but before the old one is revoked, and the gate admits a credential that is a millisecond away from being killed.
Symptom-to-Resolution Matrix Link to this section
Reprint Mints an Unrelated New Credential Link to this section
Symptoms
- Two distinct serials resolve to one
attendee_idin the credential store. - The access log shows entries from two credentials for the same person.
Root cause. The reprint path reuses first-issuance logic, creating an additive credential with no supersede relationship to the original.
Fix
- Route reprints through a dedicated supersede operation, never the create-credential routine used at first issuance.
- Bind the new serial to the same
attendee_idand increment aversioncounter, so old and new are explicitly linked as a lineage. - Mark the prior serial
supersededin the same transaction that mints the new one, so the store never holds twoactiveserials for one identity.
Old Serial Never Revoked Link to this section
Symptoms
- The reprinted badge works, and so does the badge reported lost.
- Help-desk records show a reprint, but the scanner still admits the original.
Root cause. The reprint mints a replacement but never adds the prior serial to a revocation set the gate checks.
Fix
- Maintain a durable revocation set (Redis set or a
revoked_serialstable) consulted on every scan. - Add the superseded serial to the revocation set inside the reprint transaction, not as a fire-and-forget side effect.
- Propagate the revocation to offline scanners on their next sync, and fail closed — an unsynced scanner that cannot reach the set treats unknown serials as untrusted.
No Serial or Version to Distinguish Copies Link to this section
Symptoms
- The scanner cannot tell an original badge from its reprint.
- There is no field on which to base a supersede decision.
Root cause. The credential encodes only identity, with no per-issuance serial or monotonic version.
Fix
- Encode a unique
serialand a monotonicversionin every credential, signed alongside theattendee_id. - Store the current active
versionper attendee, and at scan reject any credential whose version is below it. - Anchor the credential’s trust in a signature so a serial cannot be forged; the signing-boundary rules live in security boundary configuration.
Reprint / Scan Race Link to this section
Symptoms
- Intermittently, the old badge admits once right after a reprint, then stops.
- The double-entry is not reproducible on demand.
Root cause. Mint-new and revoke-old are not atomic, so a scan slips through the window between them.
Fix
- Perform mint-new and revoke-old as a single atomic unit — one DB transaction, or a Lua script over Redis — so no scan observes a half-applied state.
- Order the writes revoke-then-activate, so the worst-case interleaving denies a valid-but-superseded badge rather than admitting a revoked one (fail safe, not fail open).
- Make the scan check
version >= active_version AND serial NOT IN revokedas one read against a consistent snapshot.
Minimal Working Implementation Link to this section
A self-contained credential authority: a Pydantic v2 model for a signed credential, an atomic supersede-and-revoke, and a scan validator that admits the current serial and rejects the superseded one. A verification block proves the reprint kills the old badge while the new one admits, and that a version below the active line is refused. Signing uses a keyed HMAC stand-in so the module runs as-is.
from __future__ import annotations
import hashlib
import hmac
from pydantic import BaseModel, ConfigDict, Field, field_validator
_SIGNING_KEY = b"demo-key-rotate-in-prod"
def _sign(attendee_id: str, serial: str, version: int) -> str:
msg = f"{attendee_id}|{serial}|{version}".encode()
return hmac.new(_SIGNING_KEY, msg, hashlib.sha256).hexdigest()[:16]
class Credential(BaseModel):
"""A scannable badge credential. Identity + per-issuance serial + version, signed."""
model_config = ConfigDict(strict=True, extra="forbid")
attendee_id: str = Field(..., min_length=6, max_length=36)
serial: str = Field(..., pattern=r"^bd-[0-9a-f]{8}$")
version: int = Field(..., ge=1)
signature: str = Field(..., min_length=16, max_length=16)
@field_validator("signature")
@classmethod
def _verify(cls, v, info):
d = info.data
expected = _sign(d["attendee_id"], d["serial"], d["version"])
if not hmac.compare_digest(v, expected):
raise ValueError("credential signature invalid")
return v
class CredentialAuthority:
def __init__(self) -> None:
self._active_version: dict[str, int] = {} # attendee_id -> current version
self._active_serial: dict[str, str] = {} # attendee_id -> current serial
self._revoked: set[str] = set() # superseded serials
self._counter = 0
def _mint_serial(self) -> str:
self._counter += 1
return f"bd-{self._counter:08x}"
def issue(self, attendee_id: str) -> Credential:
version = self._active_version.get(attendee_id, 0) + 1
serial = self._mint_serial()
cred = Credential(attendee_id=attendee_id, serial=serial, version=version,
signature=_sign(attendee_id, serial, version))
self._active_version[attendee_id] = version
self._active_serial[attendee_id] = serial
return cred
def reprint(self, attendee_id: str) -> Credential:
"""Supersede-and-revoke: mint a new serial and revoke the old ATOMICALLY."""
old_serial = self._active_serial.get(attendee_id)
cred = self.issue(attendee_id) # bumps active version + serial
if old_serial is not None: # revoke in the same logical unit
self._revoked.add(old_serial)
return cred
def scan(self, cred: Credential) -> str:
# One consistent check: not revoked AND at/above the active version line.
if cred.serial in self._revoked:
return "deny:revoked"
if cred.version < self._active_version.get(cred.attendee_id, 0):
return "deny:superseded_version"
return "admit"
if __name__ == "__main__":
ca = CredentialAuthority()
original = ca.issue("att_51c0ffee")
assert ca.scan(original) == "admit" # original works before loss
reprinted = ca.reprint("att_51c0ffee") # attendee lost the badge
assert reprinted.serial != original.serial # a NEW serial...
assert reprinted.attendee_id == original.attendee_id # ...same identity
assert reprinted.version == original.version + 1 # monotonic version bump
assert ca.scan(reprinted) == "admit" # new badge admits
assert ca.scan(original) == "deny:revoked" # old badge no longer grants entry
# A forged serial can't ride in on the identity alone:
try:
Credential(attendee_id="att_51c0ffee", serial="bd-deadbeef",
version=9, signature="0000000000000000")
raise AssertionError("forged credential must not validate")
except ValueError:
pass
print("OK:", {"active_serial": ca._active_serial["att_51c0ffee"],
"revoked": len(ca._revoked)})
The assertions are the fix’s own regression test: the reprint issues a new serial bound to the same identity, the old serial is revoked so it stops admitting, the new one admits, and an unsigned forged serial cannot validate at all — one identity, one live credential.
Memory & Performance Constraints Link to this section
The scan path is latency-critical — a gate check must clear in single-digit milliseconds — so the revocation lookup and signature check must stay cheap under a rush.
| Component | Constraint | Mitigation |
|---|---|---|
| Revocation set lookup | Every scan checks membership; a linear scan would stall the gate | Back it with a Redis set or an indexed column; membership is O(1), never a full scan |
| Revocation set growth | Superseded serials accumulate across the event | Bound the set to the event’s lifetime, expire after teardown; the set is small relative to attendees |
| Offline scanner sync | A partitioned gate can miss a fresh revocation | Sync revocations on reconnect and fail closed on unknown serials; the sync-convergence machinery is shared with check-in kiosk sync |
| Signature verification | Per-scan HMAC/asymmetric verify is CPU work on the hot path | Use a fast keyed HMAC or cache the verified public key; keep the signed payload tiny |
| Supersede transaction | Mint-and-revoke must be atomic under reprint concurrency | One DB transaction or a Redis Lua script; never two independent writes that can half-apply |
Incident Triage & Rollback Link to this section
Fast path when the duplicate-credential alarm fires — target under fifteen minutes to containment. Every step is non-destructive until the final rollback.
- Confirm the symptom class. Query for any identity holding more than one live credential: two
activeserials for oneattendee_idmeans the reprint went additive; one active plus an admitting old serial means revocation never landed. - Size the exposure.
redis-cli SCARD revoked:serialsagainst the count of reprints issued; a revocation count well below reprints issued confirms the revoke step is dropping. - Contain at the gate. Push a scanner policy to reject any credential whose
versionis below the stored active version, even if the revocation set is stale — this closes the hole while you fix the root cause:redis-cli SET scan:enforce_version 1 EX 900. - Attribute the leak. Group double-credential cases by issuing station; a single help-desk terminal usually points at a stale client that still calls first-issuance logic, a fleet-wide pattern points at a bad deploy of the reprint service.
- Rollback. If a deploy reintroduced additive reprints,
git revert HEAD~1 --no-edit && kubectl rollout restart deploy/credential-authority. Because supersede-and-revoke is idempotent on the active version, re-driving reprints after rollback can only converge — a re-issue bumps the version and revokes the prior serial again with no double-grant. Revocation of a live credential is also the same primitive used when refund & chargeback reconciliation invalidates a badge after a payment reversal.
Post-rollback validation. Confirm no identity carries two live credentials:
# No attendee_id should have more than one non-revoked, current-version serial:
psql -c "SELECT attendee_id, COUNT(*) FROM credentials \
WHERE status='active' GROUP BY attendee_id HAVING COUNT(*) > 1;" # expect 0 rows
# Every reprint must have revoked exactly one prior serial:
psql -c "SELECT (SELECT COUNT(*) FROM credentials WHERE status='revoked') >= \
(SELECT COUNT(*) FROM reprints) AS ok;" # expect t
Frequently Asked Questions Link to this section
Why supersede-and-revoke instead of just printing another copy of the same barcode? Reprinting the identical serial seems simpler, but it defeats revocation: if the old and new badge share one serial, you cannot kill the lost one without killing the reprint too. Superseding mints a distinct serial bound to the same identity and revokes only the old one, so the attendee keeps working access while the lost badge is dead. It also gives you a per-issuance audit trail — you can see exactly which serial was live when.
What stops the race where the old badge scans during the reprint? Atomicity and write ordering. Mint-new and revoke-old run as one unit — a single DB transaction or a Redis Lua script — so no scan observes a half-applied state. Ordering the writes revoke-then-activate means the worst-case interleaving denies a valid-but-superseded badge rather than admitting a revoked one. The gate itself evaluates “not revoked and version at or above the active line” as one consistent read.
How do offline scanners handle a revocation they haven’t synced yet? They fail closed. An offline gate applies the version rule against its last-synced active version and treats any serial it cannot confirm as untrusted rather than admitting it, so a superseded badge is denied even before the revocation propagates. Revocations sync on reconnect using the same convergence approach as offline check-in reconciliation, so the window is bounded and never fails open.
Related Link to this section
- Badge Reprint Handling — the parent stage that owns how a physical badge is re-issued without weakening the access model behind it.
- Security Boundary Configuration — defines the signing and trust boundary that makes a credential serial unforgeable, which this supersede scheme depends on.
- Refund & Chargeback Reconciliation — uses the same revoke-a-live-credential primitive to invalidate a badge after a payment reversal, not just a reprint.