← worrachatrph.web.app

Enforcing a job state machine in Firestore security rules

Worrachatr Phoonbamphen · September 2026

A marketplace job moves through a few states: open, accepted, completed, cancelled. If the client app decides the transitions, anyone with a browser console can skip them. Firestore security rules can enforce the machine themselves, with no server code, if each transition is written as a small closed contract.

The pattern

Each allowed transition is one branch of allow update, and every branch answers four questions: who may do it, from which state, to which state, and which fields may change.

// an approved provider claims an open job
resource.data.status == 'open'
&& resource.data.customerId != request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'providerId'])
&& request.resource.data.status == 'accepted'
&& request.resource.data.providerId == request.auth.uid
&& providerApproved(request.auth.uid)

resource.data is the stored document and request.resource.data is what it would become. Comparing the two with diff().affectedKeys().hasOnly(...) is what stops a request from changing the status and, in the same write, the title or the price.

What this rejects

The race that rules cannot close

Two providers can claim the same open job at the same moment. Each write is judged against the document as it is stored, and Firestore commits only the first; the second then finds the status already accepted and fails. The invariant holds, but the client has to expect that failure and show "someone else got it". Rules cannot serialise anything. They judge one write at a time.

Testing it

A rule with only "allowed" tests can be wide open. For every branch I test the legal transition and its neighbours: wrong actor, wrong starting state, an extra field. Writing the suite caught a hole in my own first draft: the provider-profile read rule did not require sign-in, so anonymous callers could read approved profiles. The test failed and I fixed the rule, not the test.