· 7 min read
No-login magic links for field crews: hashed, scoped, revocable, and usable offline
Crews receiving deliveries on a jobsite will not create accounts. Here is how Yardline gives them a single link that only opens one delivery, can be revoked, and keeps working without signal.

Yardline handles the handoff between a building supplier and a contractor: a truck arrives on site, someone has to confirm what came off it, note what is missing or damaged, and sign. That someone is usually a crew lead standing on a roof or in a driveway, in gloves, with one bar of signal, who has never heard of Yardline and has no interest in creating an account.
So the crew gets a link instead. Tap it, see the delivery, mark it received. No password, no app install, no sign-up. The rest of this post is about making that link safe to hand out by text message, and sturdy enough to work when the phone is offline.

What a link like this has to guarantee
A link is a password that travels in plain sight: in SMS threads, group chats, forwarded screenshots. Before writing any code I listed what it must and must not allow:
- It opens one delivery and nothing else. Not the contractor's other jobs, not the supplier's catalog.
- It expires on its own, by default in a week.
- The office can revoke it at any time, for example when the wrong number got it.
- A database leak must not hand out working links.
- Search engines must never index it.
- Every action through it is attributable to "the crew on this link", not to the person who created it.
Creating the link
createLink: permissioned('delivery.track')
.input(z.object({
deliveryId: z.string().uuid(),
expiresInDays: z.number().int().min(1).max(30).default(7),
}))
.mutation(async ({ ctx, input }) => {
// ...check the caller is the contractor who owns this delivery...
const guestUserId = newId()
await ctx.db.insert(users).values({
id: guestUserId, tenantId: ctx.tenantId, role: 'crew_lead',
name: 'Jobsite crew', accountId: delivery.accountId,
})
const token = randomBytes(32).toString('base64url')
const expiresAt = new Date(Date.now() + input.expiresInDays * 86_400_000)
await ctx.db.insert(deliveryAccessTokens).values({
id: newId(), tenantId: ctx.tenantId, deliveryId: delivery.id,
tokenHash: hashToken(token), createdBy: ctx.session!.userId, guestUserId, expiresAt,
})
return { token, url: `${ctx.appUrl}/receive/${token}`, expiresAt }
})Each choice maps to one of the guarantees:
- 32 random bytes from the OS RNG is 256 bits. Guessing is not a realistic attack, so the token itself can be the capability.
- Only the SHA-256 is stored. The plain token exists in one HTTP response and in the URL the office sends. Someone with a copy of the database sees hashes, which cannot be turned back into links. Because the token is long and random, a fast unsalted hash is fine here. Slow hashing like bcrypt is for low-entropy human passwords.
- The link gets its own guest user with the
crew_leadrole, tied to the contractor's account. Events made through the link are recorded as "Jobsite crew", so the office user who created it is never blamed for what the crew did. - Expiry is bounded between 1 and 30 days.
- The
/receive/pages arenoindex.
Checking the link on every call
There is no session cookie for the crew. Every request carries the token, and every request resolves it again:
const [row] = await ctx.db
.select({ /* token id, guest user, the delivery and its jobsite */ })
.from(deliveryAccessTokens)
.innerJoin(deliveries, eq(deliveries.id, deliveryAccessTokens.deliveryId))
.where(and(
eq(deliveryAccessTokens.tenantId, ctx.tenantId),
eq(deliveryAccessTokens.tokenHash, hashToken(token)),
gt(deliveryAccessTokens.expiresAt, new Date()),
isNull(deliveryAccessTokens.revokedAt),
))
.limit(1)
if (!row) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'This receiving link is invalid or expired.' })
await ctx.db.update(deliveryAccessTokens).set({ lastUsedAt: new Date() }).where(eq(deliveryAccessTokens.id, row.id))Tenant, hash, expiry and revocation are all in one WHERE, so there is no path where one check is forgotten. Wrong token, expired token and revoked token all produce the same message, which tells an attacker nothing about which one it was. lastUsedAt gives the office a quiet signal that the crew actually opened the link.
Revoking sets revokedAt on the delivery's active tokens. The next request, from any phone holding that link, fails.
One set of business rules, not two
The tempting shortcut is a separate, simpler set of "crew endpoints" with their own logic. That is how two code paths end up disagreeing about whether a delivery can be received. Instead, the crew router resolves the link and then calls the normal receipts router with a synthetic session:
function withCrewSession(ctx: Context, access: CrewAccess): Context {
return { ...ctx, session: {
tenantId: ctx.tenantId, userId: access.guestUserId, role: 'crew_lead', name: 'Jobsite crew',
contractorId: access.delivery.accountId, supplierId: null, branchId: null,
deviceId: `crew-link:${access.id}`,
} }
}
// inside a crew mutation
const access = await resolveAccess(ctx, input.token)
return receiptsRouter.createCaller(withCrewSession(ctx, access)).start({
deliveryId: access.delivery.id, receiptId: input.receiptId,
idempotencyKey: input.idempotencyKey, occurredAt: input.occurredAt,
})Note that the delivery ID comes from the resolved link, never from the request. A crew cannot point their token at a different delivery by changing a parameter.
Working with no signal
On load, the receiving page fetches the delivery and caches it in IndexedDB. If the fetch fails, it shows the cached copy and says "Working offline". When the crew taps something, the action is queued with two values created on the phone at the moment of the tap: an idempotency key (crypto.randomUUID()) and occurredAt.
When the phone is back online, the queue is flushed in order, and it stops at the first failure:
const flush = useCallback(async () => {
for (const action of (await queuedActions()).filter((q) => q.input.token === token)) {
try {
await run(action)
await removeAction(action.id)
} catch {
break // keep order: never apply "signed" before "started"
}
}
}, [run, token])Stopping at the first failure keeps the order intact. If "start receipt" fails, sending "line 3 damaged" and "signed" after it would only produce confusing errors.
One small bug to learn from: the original version set "All offline updates synced." after the loop whether it finished or hit break. The message should only appear when the queue is actually empty.
Retries that cannot double count
A phone that loses signal mid-request does not know whether its write landed. It will send it again. On the server, every delivery change is an event appended to a log, and the idempotency key is unique:
return ctx.db.transaction(async (tx) => {
await tx.insert(deliveryEvents).values({
/* ... */
occurredAt: event.occurredAt ?? now,
recordedAt: event.recordedAt ?? now,
idempotencyKey: event.idempotencyKey ?? newIdempotencyKey(),
deviceId: event.deviceId ?? ctx.session?.deviceId ?? null,
})
.onConflictDoNothing({ target: deliveryEvents.idempotencyKey })
return refreshProjection({ db: tx, tenantId: ctx.tenantId }, deliveryId)
})A repeated key does nothing and is not an error. The phone gets the current state back, the same answer it would have got the first time, and removes the item from its queue.
The transaction around both statements came from a real bug. Originally the insert committed first, and then rebuilding the projection could throw on an illegal transition. The caller saw a failure, but the bad event was already in the log, and every later replay of that delivery hit the same error. With insert and projection in one transaction, either both land or neither does.
When two phones disagree
Offline writes arrive late, sometimes hours late. If the server processed events in arrival order, the office's 09:20 cancellation would win over a delivery the crew had actually received at 09:12. So events carry occurredAt from the moment of the tap, and conflicts are resolved by when things happened, not when they arrived:
- The earliest
occurredAtwins. - An office cancellation is refused if the crew had already received.
- A losing event is never deleted. It is superseded by an
EventCorrectedentry, so the full history stays readable.
The core that applies these rules is pure: no database, no network. A dependency rule enforces that the core imports no I/O, which keeps the rules easy to test. Trusting phone clocks needs care, and the native field app estimates its clock offset from the server, NTP style, before stamping events.
Server-side failures to deliver notifications are handled the same patient way: retries back off at 2 s, 8 s, 30 s, 2 min and 10 min, then hourly, and errors are split into permanent and retryable. A 401 used to be retried forever. Now it is permanent.
Checklist for your own magic links
Make the token the capability
At least 32 random bytes, base64url. Store only a hash. Return the plain token once.
Scope it to one thing
Derive the resource from the token server-side. Never accept the resource ID from the client alongside it.
Check everything in one query
Tenant, hash, expiry and revocation together, with the same error for every failure.
Give it its own identity
A guest user per link keeps the audit trail honest.
Reuse your business logic
Resolve the link, build a narrow session, call the same code your logged-in users hit.
Plan for offline from the start
Idempotency keys and event times created on the device, an ordered queue, and a server that treats a repeated key as success.
The project runs 91 tests plus a 30-test Playwright end-to-end suite, and the event core is kept free of I/O so most of the conflict rules can be tested without a database.