Door codes are the most sensitive thing a short-term rental host stores about a property. A leaked guest code is one Yelp review away. A cleaner code that wasn't rotated for a year is one bad actor away. And until this quarter, our access codes were stored exactly the way most hosts store them: as a free-text note on the reservation, copied and pasted between the welcome PDF, the host's notes app, and whatever messaging surface the guest happened to land on.
That worked. It worked for two years. Then in a single quarter we shipped three incidents — one where a code shown to a cleaner accidentally showed up in the guest pack, one where a checked-out guest came back a week later and the codes still let them in, and one where an audit request came in and we had no answer for "when did this code last change." Three different problems. One root cause: codes weren't first-class records. They were notes.
So we rewrote it. The result is Access Packs v4 — a complete teardown of how we store, deliver, rotate, revoke, and retire access credentials. It is the biggest non-billing change we have shipped this year, spanning 19 PRs across schema, host UI, guest UI, worker jobs, integrations, backfill, and per-reservation overrides.
This post explains what changed and why, in the order the changes matter.

Slots and packs
The first thing to understand is the new noun structure. A slot is the definition of one credential on one device — the front door lock, the garage keypad, the Wi-Fi network. Slots live once per property and describe what the credential is, who can see it, and how often it rotates.
A pack is a snapshot of credentials for one trip and one audience. When a reservation is created, the worker builds three packs from the property's slots, filtered by audience: a guest pack, a cleaner pack, and (if the property has maintenance slots) a maintenance pack. Each pack has its own access window — guests get check-in through check-out, cleaners get a 12-hour turnover band, maintenance gets a 24-hour ad-hoc window.
The slot-and-pack split was the unlock. Once each credential belongs to a per-audience pack instead of a free-text note, every other problem becomes solvable.
Encryption at rest
The old codes were stored as plain text. Anyone with read access to the reservation row could read them. The new codes are encrypted under a ACCESS_PACK_MASTER_KEY that lives only in the Railway environment for the API and worker. The encryption key is per-environment — staging and production are distinct, so a staging incident never reveals a production secret.
The schema includes a encryption_key_id column for future key rotation. There is no rotation path yet, but the column is the hook. When we are ready, a key-rotation worker can decrypt with the old id, re-encrypt with a new id, and update the row atomically. Doing this work later is a much smaller cost than retrofitting it after the fact.
The lifecycle
Every code now has a lifecycle, and every transition writes an audit row.
- Created at pack generation.
- Delivered when the audience first opens the pack (guest opens the stay portal; cleaner opens the cleaner page).
- Rotated when a checklist step with action
rotate_access_codesfires, or when a host clicks Rotate on the Reservation Access page. - Revoked when a checklist step with action
revoke_access_codesfires, or when a host clicks Revoke. - Redacted 30 days after check-out by the worker. The encrypted value is permanently destroyed; the audit row stays.
Two of those transitions — rotated and revoked — are wired into the checklists you already run. We treat the turnover checklist as the canonical rotation trigger. If your cleaner marks the rotate step complete, the new code is live before the next guest opens the portal. No host clicks required.

Per-audience filtering
The old codes were one bucket. Everyone who could see the reservation could see every code. The new packs are filtered by audience. A slot tagged cleaner never appears in a guest pack. A slot tagged maintenance never appears in a cleaner pack. The default for most slots is all — guest, cleaner, and maintenance all see it — but the moment you want to restrict, you tag.
Cleaners and maintenance vendors never see guest names, contact info, or message threads. They see the property name, the codes they need, the window they need it for, and a link to the turnover or ad-hoc checklist. That's it. We enforce this both at the API layer (the service role filters by audience before returning rows) and at the RLS layer (cleaner accounts can't query guest data even with a forged request).
Per-reservation window override
The default access window covers the predictable case. The override handles the exception. If you grant a guest early check-in at 11am instead of 4pm, you set the override on that one reservation, the codes work at the new times, and every other reservation on the property continues to use the default. The override is stored as a delta on the reservation — never as a mutation of the property slot — so nothing leaks from one trip to another.
We shipped a follow-up specifically so rotation respects the override. Earlier in v4, a rotation triggered after an override would snap back to the property default. That was wrong. Now the new code generated by your turnover checklist honors the extended window, not the original.
Integration with smart locks
Manual is always available. The current smart-lock integration is Igloohome, in beta. It uses OAuth client credentials — one connection per tenant covers every Igloohome lock in your portfolio. When the worker rotates a smart-lock slot, it pushes the new PIN to the lock and reads it back to confirm. If the lock is offline or its battery is low, the rotation stays in in flight until the lock confirms, and the checklist step blocks until you resolve.
We will be adding more providers as we validate them. The slot model is provider-agnostic by design — adding a new provider is a new entry in the provider enum and an adapter, not a schema change.

The encryption key has no escrow yet
This is the one trade-off we want to be honest about. The ACCESS_PACK_MASTER_KEY lives only in Railway environment variables and our operator's vault. There is no third-party escrow path — no Vault, no KMS, no recovery service. If we lost both copies, every encrypted code would be permanently unrecoverable.
We accept this trade-off in v4 because codes are rotatable. If we ever needed to walk away from the key, hosts could re-enter every code manually within a few hours of work per property. That's a real cost, but it's a recoverable one. Before we store any longer-durability encrypted data — payment tokens, identity documents — we will add a wrapped-key escrow path. The encryption_key_id column is the hook for that work too.
What it cost us
The rewrite spanned 19 pull requests over four weeks. That's a lot of investment for a feature that, on the surface, did not change what your guests see (the welcome PDF still shows the same code). The investment is in the surface area that broke before — the cleaner who shouldn't see guest codes, the checked-out guest who shouldn't still have access, the audit request that needs an answer.
The visible payoff comes the first time a checklist step rotates a code without a human in the loop, the first time a guest's code stops working at 4:01pm on check-out day, and the first time a host opens the Reservation Access page and sees exactly who has what. We have been running this in production for two weeks; those moments are landing daily.
What's next
The roadmap is short and concrete: more smart-lock providers, wrapped-key escrow, and a key-rotation worker. None of these are features hosts see. All of them make the system safer in ways hosts depend on.
If you're running ArrivHQ and you have not toured the new Property → Access Settings panel yet, that is the right starting point. The Access Packs overview walks through slots, packs, audience, and rotation in detail. The Reservation Access page is where the day-to-day work lives.
Door codes deserve to be first-class records. They are now.