All articles Mobile

One Free Tier per iPhone: Device Limits in an iOS App

On the web, free-tier abusers rotate emails and browsers. In an iOS app they rotate Apple IDs, “Hide My Email” relay addresses and reinstalls. Sign in with Apple makes the email side almost free: every signup can arrive with a fresh relay address. If your free tier is keyed on email, an iPhone can collect as many free tiers as the user has patience for.

The thing that does not change between those attempts is the phone. This guide uses the Prynt Swift Package to limit free accounts per device: identify at signup, verify on your server, link the new account, and add App Attest and integrity signals for the cases where the “phone” is not what it claims.

Why the obvious iOS identifiers fall short

  • identifierForVendor is stable while at least one of your apps is installed. Delete them all and reinstall, and it changes. That is the exact behavior of someone recycling free tiers.
  • The advertising identifier requires App Tracking Transparency consent, is zeroed when the user declines, and is not meant for this purpose.
  • Your own UUID in UserDefaults disappears with the app.

The Prynt SDK persists its own device id in the Keychain, which survives an app reinstall, and sends it with device model, OS, screen and hardware details. The server fuses these into a visitorId. Erasing the device or switching to another iPhone yields a new identity, which is the right outcome: a second physical phone is a real cost to the abuser.

Step 1: add the package and identify at signup

Add the package with Swift Package Manager and import the Prynt product. Create one instance with your public key and identify when the signup screen submits.

import Prynt

final class SignupViewModel {
    private let prynt = try? Prynt(apiKey: "pk_live_…")

    func submit(email: String) async throws {
        var requestId: String? = nil
        if let prynt {
            requestId = try? await prynt.identify(tag: ["action": "signup"]).requestId
        }
        try await api.signUp(email: email, pryntRequestId: requestId)
    }
}

identify(tag:linkedId:) returns a result with requestId, visitorId, visitorFound and confidence. Only send the requestId to your server. Values computed on the device are hints; the decision belongs on the server.

If identification fails, the code still signs up with a nil request id. Your server decides what a missing id means, which avoids blocking real users on a flaky network.

Step 2: verify on your server

Your backend fetches the event with your secret key. The secret key must never ship inside the app binary.

import { PryntServer } from '@prynt/node';
const prynt = new PryntServer({ secretKey: process.env.PRYNT_SECRET_KEY });
const MAX_FREE_ACCOUNTS_PER_DEVICE = 1;

export async function signUp(req, res) {
  const { email, pryntRequestId } = req.body;
  let freeTier = true;

  if (pryntRequestId) {
    try {
      const event = await prynt.getEvent(pryntRequestId);
      if (event.decision === 'block') return res.status(403).json({ error: 'signup_unavailable' });
      if (event.linkedId) return res.status(403).json({ error: 'signup_unavailable' }); // replayed id
      if (event.accountsOnDevice.count >= MAX_FREE_ACCOUNTS_PER_DEVICE) freeTier = false;
    } catch {
      // Prynt unreachable: create the account, decide on the free tier later
    }
  }

  const user = await createUser({ email, freeTier });
  if (pryntRequestId) {
    await prynt.updateEvent(pryntRequestId, { linkedId: user.id }).catch(() => {});
  }
  res.json({ userId: user.id, freeTier });
}

Notice the policy: a second account on the same iPhone is allowed, but it does not get the free tier. That keeps families sharing an iPad and people who genuinely need a second account unblocked, while removing the reason to farm. If your free tier is expensive, you can block instead. The device-based signup limits guide discusses both choices.

The updateEvent call is essential. Without it, no account is ever attached to the device, and accountsOnDevice.count stays at zero forever.

Users who reinstall and log back into an existing account should not look like new signups. Identify on login with the user’s id:

let result = try await prynt.identify(tag: ["action": "login"], linkedId: user.id)

This links existing accounts to the device going forward, which matters for users who signed up before you added the SDK, and enables account-level signals such as device spread on paid plans. Once per launch or per session is plenty; each identify counts toward your monthly quota.

Step 4: integrity and App Attest

Free-tier farming on iOS at scale rarely happens on stock phones. It happens on jailbroken devices, with instrumentation frameworks hooking the app, or with modified builds that strip your checks. The SDK collects raw evidence for the server to judge:

  • Jailbreak tells: known jailbreak files, package-manager URL schemes, a sandbox escape test, fork() succeeding, and injected libraries such as MobileSubstrate.
  • Instrumentation: the Frida control port, injected Frida libraries and a traced process.
  • Cloned or side-loaded builds: receipt presence, bundle id and debug-build indicators.

These surface as signals and reason codes such as ROOTED_OR_JAILBROKEN, INSTRUMENTATION and CLONED_APP. The SDK deliberately does not return an “isJailbroken” boolean the app could flip; the server makes the call.

App Attest adds hardware-backed evidence. On iOS 14 and later, with the App Attest capability enabled in your app, the SDK generates an attestation and sends it with the identification. When your Prynt account is configured to verify App Attest, a failed verification raises FAILED_ATTESTATION. If App Attest is unsupported or not configured, identification continues without it. The App Attest explainer covers what it can and cannot prove.

A reasonable policy: on any of these signals at signup, withhold the free tier regardless of the account count.

What happens after a reinstall

ScenarioSame visitorId?Effect
Delete and reinstall the appYes (Keychain persists)Previous accounts still counted
Sign in with a new Apple ID / relay emailYesPrevious accounts still counted
Erase all content and settingsNoNew device; costs the abuser a reset
A different iPhoneNoNew device
Simulator or modified buildVariesIntegrity signals flag it

Android is different: it has no reliable store that survives an app uninstall, so the Android SDK anchors identity on a combination of its own id, ANDROID_ID, device characteristics and, when configured, Play Integrity. The mobile device fingerprinting guide covers both platforms.

Ship it

Add the package, identify on signup and login, verify and link on your server, and start with “second account, no free tier” rather than a hard block. Watch which devices hit the limit for a few weeks before tightening. The Swift Package and the other mobile SDKs are listed on the SDKs page.

Try it free

Prynt is device intelligence with a free tier — visitor IDs, bot & fraud Smart Signals, and behavioral biometrics, powered by a cross-site network. Start free.

Keep reading