Field Notes · Trust & Safety

Free Tokens Are a Handshake

How we kept ours meaningful: a way to recognize a returning identity at signup, so the grant gets claimed once per person instead of once per fresh Gmail address. Plus what a string of incremental Gmail signups taught us about defending a goodwill gesture.

Nervous EnergyJune 4, 20262 min story · 8 with the deep dive

A few months back we started catching a recurring pattern in our signup logs. A handful of users had figured out a trick with Gmail addresses: tack a digit onto one (foo@, then foo1@, then foo2@, and so on) and Google hands you what it considers brand-new accounts. We were greeting each one as a brand-new visitor. Brand-new visitors get a starter grant of tokens so they can try the app, and by the time we caught on, a few of these folks had quietly worked their way through six or seven signups apiece.

This wasn't a fraud ring or a botnet. It was a handful of people with some time on their hands, Gmail accounts, and enough patience to keep tapping "Sign Up." We ended up grateful for them. They found a gap in our product and kept walking through it, and unlike most of our users they left a trail in the logs we could sit down and read.

Screenshot placeholder

Slack alert that kicked it off

Replace with a redacted screenshot of the original returning-device Slack notification.

The tokens themselves aren't the real concern. They're cheap at these volumes, and we hand them out on purpose: Diffusitron gives new users a so they can run a handful of generations and decide whether any of this is for them, without pulling out a wallet first. What actually costs us is the honesty of our signup metrics. If a meaningful fraction of the people we count as new users are the same person signing up over and over, our acquisition funnel stops telling us the truth, and every product decision downstream of those numbers ends up resting on something that isn't quite real.

The grant is a handshake. When the handshake is farmable, the numbers built on it stop meaning anything.

The design followed from one honest admission: the people we actually see doing this are casual, not sophisticated. The casual multi-grabber wants a few extra grants via fooN@gmail.com or cycling, and a browser and some patience is all it takes. The farmer wants many grants from many fresh accounts and needs scripts and a supply of identities. The sophisticated faker wants to proxy real and look like many genuine devices, which takes real hardware, the signed app, and serious engineering. We build for the first two because those are the cases we actually observe. The sophisticated attacker we price out: a starter grant is worth pennies, so once defeating us costs real hardware and real engineering time, the math breaks for them long before it breaks for us.

The fix is conceptually simple: recognize a returning identity at signup, so the grant is claimed once per person rather than once per email address. Firebase gives every signup a fresh UID, which is sensible default behavior but means the backend has no memory of returning users on its own. We needed a memory that survives account deletion and lives outside the user tree. We built it in three layers: an identifier that Apple and Google hand back every time the same person signs in regardless of how many times they delete their account, a ledger as a secondary check, and then a hardware-level that Apple persists across reinstalls and factory resets. The first two layers handle roughly ninety percent of the easy cases. The device layer catches the rest, including the Gmail digit trick, because foo1@gmail.com really does have a different identity than foo@gmail.com, but it's being typed on the same phone. One extra wrinkle closed the obvious hole: we burn the device bit even when we refuse a grant at a higher layer, so a blocked identity can't come back through a fresh account with a clean device record.

A reader who wants just the idea can stop here. A reader building something similar will find the mechanics below: how we stack these layers, how blocks at one layer inform the next, and where the unavoidable collateral damage lands.

The deep dive
That's the story. Below is how we actually built it, in detail, for anyone who wants to implement something similar. If that's not you, this is a clean place to stop.

What we had to work with

Before trying to solve anything, we wrote down what we actually had to work with. Most of the design fell out of that list.

Firebase mints a brand-new UID every time anyone signs up, which means the backend has essentially zero memory of returning users unless we go out of our way to give it some. Any record of who already got a starter grant has to live outside the user document, because otherwise it gets wiped the moment an account gets deleted.

The signals we could reach for, roughly in order of cheapest to most expensive:

  • . OAuth hands us a stable account ID for Sign In With Apple and Google. Same account, same sub, forever.
  • Normalized email. User-controlled, but canonicalizable (lowercase, trim, fold).
  • Device identity. Apple has for iOS, which persists a couple of bits keyed to the hardware itself.
  • Proof the caller is our app. sits under everything else here, so none of the signals above can be bypassed with curl. We cover it in its own post.

The first two layers: provider and email

We made a top-level collection called claimedProviders. The first time a given user gets a starter grant, we write two documents into it: one keyed by the provider's sub, and one keyed by the normalized form of their email.

claimedProviders/
claimedProviders/{providerSub}
claimedProviders/email:{normalized}
  ├── provider, email, claimedAt
  └── originalUid   // breadcrumb for support

Because this collection sits outside the main user tree, it doesn't get cleaned up when an account gets deleted. Someone who deletes their account and tries to come back still hits the same wall they left through.

When one of those keys matches on a new signup, the grant gets refused but the signup itself still completes, because we don't want to break auth flow for someone with a legitimate reason to be back. The new account's vault gets marked with a grantStatus of blocked_provider or blocked_email, zero tokens get issued, and a Slack alert goes out:

#starter-grants
🔁 Returning provider detected: starter grant blocked (trigger)
New UID: RKxQVO6mlqOMRHFk5n65gXjJunY2
Email: 4qp76kvj6j@privaterelay.appleid.com
Matched via: provider (000967.13f5d88ba394431da851c396466cae10.1022)
Provider: Apple
Original UID: bwizh1RVyPPttB8bGAyhGGw1Htn1
Original claim: 2026-04-12T08:17:53.909Z

The Original UID is the account that first claimed the grant, and together with the New UID it lets support answer "is this actually the same person trying again?" without digging around in Firestore. The Matched via line tells us which layer caught the return, which matters for spotting : the email layer catches legitimate auth-method switches more often than the provider layer does, so knowing which one fired is the quickest read on what kind of block we're looking at.

The provider sub is worth dwelling on, because we suspect a lot of developers don't realize how useful it is. That long string is the OIDC sub claim from Apple: a stable, app-specific identifier Apple hands you back every time the same user signs in, no matter how many times they delete their Firebase account. Firebase Auth papers over it with its own UID abstraction, so for most work it never gets looked at. But if you're doing anything that needs to survive account deletion, the provider sub is often the strongest identifier you already have, and it costs nothing to start recording.

Sign up vs. sign in

With OAuth, both paths run through the same signInWithCredential call and look identical at the SDK level. Firebase exposes an additionalUserInfo.isNewUser flag, but we don't gate on it: a user can sign up today, delete their account tomorrow, and sign up again next week, and Firebase will set isNewUser to true on that second signup. The claim ledger already answers the question we actually care about, "has this identity already claimed a grant?", so we run the claim check on every grant attempt regardless of how the user got there.

What neither key catches is a genuinely new Google account. foo1@gmail.com really does have a different sub than foo@gmail.com, and a different canonical email too, so both keys wave them through. At this point we caught maybe ninety percent of the easy cases and left the Gmail cyclists right where they started. So we needed a signal tied to the device, rather than to the account.

The third layer: asking Apple to remember

DeviceCheck is minimal by design: two bits of storage per device per Developer Team ID. Those two bits persist across app reinstall, factory wipe, and Apple ID rotation, keyed to the hardware rather than to any user-facing identity. There's no software-level action a user can take to clear them.

A few caveats worth knowing:

  • Used iPhones. The bits stick around when a phone changes hands. A previous owner who burned bit0 hands that state to whoever puts their SIM in next. This is where our collateral-damage budget feels worst, because the affected person did nothing wrong.
  • Simulators and failures. Simulators don't support DeviceCheck, so QA builds take a bypass path. The call to Apple can also fail on a network blip. We with follow-up reconciliation, since missing one grant costs less than stranding a real new user, but it's worth deciding on purpose rather than letting your error handler decide for you.
  • The signing key. The .p8 key Apple gives you for signing DeviceCheck calls is real credential material. Treat it like any other production secret.

We use bit0 to mean this device has already claimed a starter grant, and once we set it we never clear it. bit1 is unused; it's its own story, told in the section below. The call shape:

deviceCheckClient.ts
// After a grant decision, mark the device forever.
await deviceCheck.updateTwoBits({
  deviceToken,        // opaque token the iOS client generates
  bit0: true,         // "this device has claimed a starter grant"
  bit1: false,        // unused; see allowlist note below
  timestamp: Date.now(),
});

This is iOS only, because that's what we ship today. We don't have an Android device layer and won't imply otherwise.

How the layers ended up helping each other

In our first pass, DeviceCheck only burned bit0 on a successful grant. That left a gap: imagine our foo1@gmail.com user gets blocked by the email layer, so the grant is refused and nothing gets written to DeviceCheck, because the grant never happened. A month later the same person mints a fresh Google account with a brand-new canonical email, and this time there's no sub in our records, no email match, and no DeviceCheck history, because we never burned the bit.

The fix was about five lines: burn bit0 whenever we refuse a grant on a provider or email match, not just when we issue one. If a layer below caught this device, we've learned something about the hardware, and that's worth recording at the hardest-to-wash-off layer we have. Each block enriches the next one, and the system gets sharper over time without any of the pieces getting more complicated.

A block at one layer should make the next one harder to slip past.

Letting some people back in on purpose

The design above had a real ergonomic problem: our own TestFlight cycles were burning bit0 on every internal device we tested with. The original plan was to use bit1 as a tester allowlist. When we actually built that, we found a better answer.

bit1 is a device-level signal, good at answering "is this device allowed in?" and bad at answering "is this person allowed in?" A tester who gets a new phone needs someone to remember to set it, and a tester who loans their phone out hands the bypass to whoever borrowed it. The two-bit budget was too coarse for a problem that was about identities, not hardware.

What we shipped instead is a small Firestore document at grantConfig/allowlist, with three lists: provider subs, normalized emails, and email domains. The grant checks all three before it touches DeviceCheck. A match short-circuits the device layer entirely: no bit read, no bit written, grant issued. Domain matching ended up the most-used, because allowlisting all of nervous-energy.com at once means a new hire stops being a coordination problem.

  • The allowlist bypasses DeviceCheck but not layers one and two. An allowlisted tester who already claimed a grant under their @nervous-energy.com Apple ID still can't claim a second one on the same provider sub. The allowlist fixes the device-coarseness problem; it isn't a license to issue infinite grants.
  • Allowlist hits skip the bit-burn on both the success and block paths. That's what actually solves the TestFlight problem. Burning bit0 for an allowlisted user would permanently mark internal devices; skipping it keeps the device clean for whoever holds it next.

The moment we caught ourselves rationing two bits across the tester problem and the dedup problem, we'd gone wrong. The tester problem was about who someone was, and we were trying to answer it with storage that only knows about hardware. Moving it into the claim ledger, where identities already lived, made the friction go away.

The parts that still cost us

No fraud control ships without some collateral damage baked in:

  • A legitimate returning user who tries to re-sign up gets zero tokens and has to reach out to us. We try to make that easy by leaving an originalUid breadcrumb in the claim record, so support has enough context to make a reasonable judgment call.
  • On a shared household device where one family member already signed up, a second family member with their own account gets blocked. Same support path applies.
  • On a used iPhone whose previous owner already signed up, the new owner can hit the same wall with no useful breadcrumb. The originalUid points at someone they have no relationship to, which makes the support path more awkward. We treat these as a manual override case by case.
  • External testers we add to TestFlight before adding them to the identity allowlist can still burn bit0 on their devices. The window between "we sent them a build" and "we remembered to allowlist them" is small but real.

None of these are bugs we're embarrassed about. They're the unavoidable collateral-damage budget of doing this kind of work. What we try to do is know what that budget costs and staff support to absorb it.

We rolled this out in stages rather than all at once, dormant then observe-only then enforce, because with fraud controls the false positive is the expensive mistake. How we roll these out is a methodology we reuse everywhere, so it gets its own writeup. Keeping a granted account's usage healthy after signup is its own problem too, covered in Trust Is Additive. And the per-user trust score built from App Check observations is what tells us which accounts have earned the benefit of the doubt over time.

What we'd take with us

  • The real reason to protect a starter grant is the integrity of everything you'll measure downstream of it. The cost of the tokens is almost beside the point.
  • Be honest about who you're defending against. Ours are casual, so we built for casual and priced the sophisticated attack out rather than pretend we'd beaten it.
  • Stack cheap signals before reaching for expensive ones. Provider sub and normalized email are basically free to check, so save your device-attestation budget for the gaps they can't close.
  • Pick identifiers that survive account deletion, because a dedup key that gets wiped with the user isn't really a dedup key.
  • Burn the device bit on every block, even one you catch at a higher layer, so a blocked identity can't return through a fresh account with a clean device.
  • When we tried to answer "who is this person?" with two bits of hardware state, we'd picked the wrong tool. Identity questions belong in the ledger, not the device.