Sign in with iMessage: passwordless login without SMS codes
Sign in with iMessage, built on ZeroSettle, lets a user log in by tapping Send on a prefilled iMessage: your app opens Messages with "SIGNIN <code>" addressed to your number, your webhook matches the code, and your auth provider issues the session. ZeroSettle's docs say the flow "verifies possession of an iMessage handle, the same guarantee as SMS OTP", with no SMS gateway, no OTP fee and nothing to type. Because the user texts first, there's no new-contact limit, and the cost is the flat plan ($49 a month on Pro); keep your existing sign-in for Android.
Key takeaways
- What it is: the user sends one prefilled iMessage, "SIGNIN" plus a short code, to your ZeroSettle number; your backend matches the code from a signed webhook and your auth provider issues the session.
- What it proves: possession of an iMessage handle, the same guarantee as an SMS code, plus the handle Apple attached to the message.
- Limits: sign-ins use none of the new-contact allowance, because the user texts the line first, and inbound messages are unlimited.
- Price: Pro is $49/month for one number; Growth is from $599/mo. No per-message or per-code fee.
- What it does not do: reach Android users. There is no SMS fallback, so keep your existing sign-in beside it.
Who uses iMessage for sign-in?
Sign in with iMessage suits iOS and web developers whose users mostly carry Apple devices and who run Firebase, Supabase, Auth0, Clerk or their own auth. ZeroSettle is an iMessage API: a real phone number on real iMessage, running on Apple hardware we operate for you, that your code texts customers from and hears back on through a bearer token and signed webhooks.
Sign in with iMessage replaces the outbound SMS one-time code. Firebase Phone Auth, for example, is "billed per SMS sent" (Firebase pricing), and every code goes out to a number nobody has verified yet. With Sign in with iMessage, ZeroSettle sends nothing until the user has texted your number, and then only a 👍 tapback. The trade-off is a trip to Messages: the user leaves your app to tap Send, then comes back signed in.
Only confirming a number on an existing account? Read phone verification without outbound SMS. More jobs are on the use-case hub.
How iMessage for sign-in works, step by step
Sign in with iMessage reverses the OTP direction: the user texts a code to your ZeroSettle number, and the signed webhook tells your backend which handle sent it.
- Start. Your backend creates an attempt with a single-use code. In the docs' reference implementation that's 6 characters from a 32-character alphabet, valid for 10 minutes.
- Send. The app opens
sms:+18055550100&body=SIGNIN%204F7K2Q: Messages opens to your number with "SIGNIN 4F7K2Q" prefilled, and the user taps Send. - Receive. ZeroSettle POSTs a signed
message.receivedevent withhandleandtextto your webhook, about 1–2 seconds after the line receives it. - Match and mint. Your backend matches the code, marks the attempt verified with that handle, mints a session with Firebase, Supabase, Auth0, Clerk or your own auth, and puts a 👍 tapback on the text with
POST /api/imessage/reactions. - Sign in. The app polls the attempt every 2 seconds and receives the session, once, when it is verified.
- On a desktop that isn't a Mac, the page shows a QR code of the same
sms:link, plus the number and code as text. The user sends it from their iPhone, and the browser tab signs in.
The handle in the webhook is what Apple attached to the message: an E.164 number for almost everyone, or an email address for iMessage-only devices. Store it exactly as returned, as the user's verified identifier. The iMessage webhook guide covers signing and retries, and sms: links and QR codes covers the link itself.
Example conversation: sign-in
One user's two sign-ins in a day, as the thread appears in the ZeroSettle console: first from the iPhone app, then from a Windows laptop through the QR code.
- Today 8:14 AM · Acme Notes iPhone app
- Customer: SIGNIN 4F7K2Q👍
- The app shows "Welcome back, Alex"
- Today 6:02 PM · Windows laptop, QR code scanned with Alex's iPhone
- Customer: SIGNIN 9QX2LM👍
- The desktop page shows "Signed in"
Setup: the docs' reference implementation
The docs' reference implementation is one backend you run, with three routes (start, webhook, status) and a provider-specific mint_session; ZeroSettle only carries the text. The Sign in with iMessage section of the docs adds the Swift and browser code and the Supabase, Auth0 and Clerk versions of mint_session.
Register the webhook
BASE=https://portal.zerosettle.io
curl -s -X PUT -H "Authorization: Bearer $ZEROSETTLE_TOKEN" -H 'Content-Type: application/json' \
-d '{"url":"https://auth.example.com/webhook"}' $BASE/api/imessage/webhook
Store the secret in the response as ZEROSETTLE_WEBHOOK_SECRET. It is shown once, and registering again rotates it.
The backend
# auth_imessage.py: run with uvicorn auth_imessage:app --port 8100
import hashlib, hmac, json, os, re, secrets, time
import httpx
from fastapi import FastAPI, Request, Response, HTTPException
app = FastAPI()
BASE = os.environ.get("ZEROSETTLE_BASE_URL", "https://portal.zerosettle.io")
TOKEN, SECRET = os.environ["ZEROSETTLE_TOKEN"], os.environ["ZEROSETTLE_WEBHOOK_SECRET"]
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I
attempts: dict[str, dict] = {} # use Redis in production
by_code: dict[str, str] = {}
def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
try:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = int(parts["t"]), parts["v1"]
except Exception:
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/auth/imessage/start")
def start():
aid, code = secrets.token_urlsafe(16), "".join(secrets.choice(ALPHABET) for _ in range(6))
attempts[aid] = {"code": code, "exp": time.time() + 600, "state": "pending", "handle": None, "session": None}
by_code[code] = aid
return {"id": aid, "code": code}
@app.post("/webhook")
async def webhook(req: Request):
raw = await req.body()
if not verify(SECRET, req.headers.get("X-ZeroSettle-Signature", ""), raw):
return Response(status_code=401)
ev = json.loads(raw)
if ev["event"] != "message.received":
return {"ok": True}
m = ev["message"]
match = re.search(r"\bSIGNIN\s+([A-Z2-9]{6})\b", (m.get("text") or "").upper())
aid = match and by_code.pop(match.group(1), None)
a = aid and attempts.get(aid)
if not a or a["state"] != "pending" or a["exp"] < time.time():
return {"ok": True} # ignore junk, expired, or replayed codes
a.update(state="verified", handle=m["handle"], session=await mint_session(m["handle"]))
async with httpx.AsyncClient() as c: # acknowledge with a tapback; no text needed
await c.post(f"{BASE}/api/imessage/reactions", headers={"Authorization": f"Bearer {TOKEN}"},
json={"message_id": m["id"], "reaction": "like"})
return {"ok": True}
@app.get("/auth/imessage/status")
def status(id: str):
a = attempts.get(id)
if not a:
raise HTTPException(404)
if a["state"] == "pending" and a["exp"] < time.time():
a["state"] = "expired"
out = {"state": a["state"]}
if a["state"] == "verified":
out["session"] = a.pop("session") # one-time read
attempts.pop(id, None)
return out
The webhook calls mint_session, so add one to the same file. For Firebase, it finds or creates the user by handle and returns a custom token for the app's signInWithCustomToken:
# pip install firebase-admin
import firebase_admin
from firebase_admin import auth
firebase_admin.initialize_app() # GOOGLE_APPLICATION_CREDENTIALS points at a service account
async def mint_session(handle: str) -> dict:
try:
user = auth.get_user_by_phone_number(handle) if handle.startswith("+") else auth.get_user_by_email(handle)
except auth.UserNotFoundError:
user = auth.create_user(phone_number=handle) if handle.startswith("+") else auth.create_user(email=handle, email_verified=True)
token = auth.create_custom_token(user.uid, {"imessage_verified": True})
return {"provider": "firebase", "custom_token": token.decode()}
How each auth provider issues the session
| Auth | How the session is issued | Email handles |
|---|---|---|
| Firebase | Admin SDK custom token, exchanged in the app; it expires after one hour | Yes |
| Supabase | The Send SMS hook hands you the OTP instead of sending it; the app calls verifyOTP | No (E.164 only) |
| Auth0 | Passwordless SMS with a custom phone provider; its Action posts the code to your backend instead of sending it | Not in ZeroSettle's docs |
| Clerk | POST /sign_in_tokens; the app redeems the ticket | Yes |
| Your own auth | Your usual cookie or JWT for that handle | Yes |
Provider docs: Firebase custom tokens, Supabase Send SMS Hook, Auth0 passwordless SMS, Clerk sign-in tokens.
What breaks sign-in, and the fix
Sign in with iMessage breaks in six known places, each with a fix. For tokens and signing, see how ZeroSettle secures its iMessage API.
| Situation | What to do |
|---|---|
| Safari ignores the button | Safari drops sms: navigations that aren't the direct result of a tap: set location.href in the click handler, not window.open after an await. |
| The code expires | After 10 minutes the status is expired; offer your usual sign-in. |
| Someone else sees the QR code | The code alone fetches nothing. Keep the attempt id out of the QR and return the session once. |
| The line is Managed by AI Agent | The hosted agent takes inbound texts and your webhook isn't called. Keep the sign-in number on Managed by API. |
| The visitor is on Android | sms: opens the SMS app. Show the QR so they can send from an iPhone, or your fallback sign-in. |
| Many users sign in at once | The line sends one thing at a time, so a tapback can come back 429 line_busy. The attempt is already verified; send the tapback after answering the webhook, which must respond within 10 seconds. |
Limits and consent for sign-in
Sign in with iMessage never uses ZeroSettle's new-contact allowance, because every sign-in starts with the user texting the line; the cost is the flat plan ($49 a month on Pro).
| Part of the sign-in | Counted as | Limit |
|---|---|---|
| The user's SIGNIN text | Inbound message | Unlimited |
| Your 👍 tapback | Reaction, never a new contact | None |
| A later text to that user | An ongoing conversation (they texted first) | No new-contact limit (2 unanswered messages per contact, on every plan) |
Calls to your /start route | Your server, not ZeroSettle | Yours to set: rate-limit per IP and device |
So 300 sign-ins on one Pro number in a day add 300 inbound texts and no new contacts; all 5 of the number's new contacts for the day stay free.
Signing in isn't marketing consent
The user texts to prove a handle, not to hear offers, so ZeroSettle's docs advise asking separately before sending anything promotional, and sending it only between 8 am and 9 pm in their time zone. Consent rules vary by country and use; this isn't legal advice, so check with counsel. A user who texted STOP earlier can still sign in, because they're the one texting, but the confirming tapback to them is blocked with 403 recipient_opted_out, so don't depend on it. See how STOP works on a ZeroSettle line.
When ZeroSettle is not a fit for sign-in
Sign in with iMessage is the wrong primary login in these cases:
- Most of your users are on Android. The user has to send from iMessage, and ZeroSettle has no SMS or email fallback.
- You need phishing-resistant sign-in, such as hardware security keys. NIST SP 800-63B covers this pattern, a code carried from the session to an out-of-band device by typing or a QR code, and says "Out-of-band authentication is not phishing-resistant"; AAL3 requires an authenticator that is. A user tricked into texting someone else's code signs that person in to the user's account. Use Sign in with iMessage where an SMS code would do.
- Users sign in on a desktop with no iPhone nearby. The QR flow needs an Apple device with iMessage to send from.
- You can't run a webhook (or a poller) to match codes. Matching the text to an attempt and minting the session happen on your server.
How much does iMessage for sign-in cost?
Sign in with iMessage costs the ZeroSettle plan price, with no per-message or per-code fee: messages are unlimited (counted). See the plans.
| Plan | Price | Fits |
|---|---|---|
| Pro | $49/month, no setup fee | One number, self-serve; unlimited messages with people who message you first, and its 5 new contacts a day per number don't apply to sign-ins |
| Growth | From $599/mo | Several numbers working as one; ZeroSettle sets it up after a demo |
Both plans are billed monthly with a 30-day money-back guarantee on the subscription, and sign-ins use no AI credits. Get a number to start.
Frequently asked questions
Is Sign in with iMessage more secure than SMS codes?
About the same. It proves possession of the handle, as an SMS code does, and adds the handle Apple attached to the message, which ZeroSettle's docs call a stronger identifier than a number the user typed. Neither is phishing-resistant under NIST SP 800-63B.
Can users sign in from an iPad or a Mac?
Yes. On an iPad, or a Mac with Messages, the button opens Messages directly. From an iMessage-only device the handle can be an email address, which Firebase, Clerk and your own auth accept but Supabase phone auth does not. That email is a different handle from the user's number, so link the two to one account rather than letting mint_session create a second.
What about users who already have an account, such as through Sign in with Apple?
Attach the verified handle to the existing account instead of creating a second one. Most providers have a link or update-user call; in Firebase, auth.update_user sets phone_number on the existing record.
Do I need a webhook for Sign in with iMessage?
No. You can poll GET /api/imessage/messages?after_id=… instead; inbound messages appear there within about 1–2 seconds. ZeroSettle's docs prefer the webhook because it is pushed and signed.
Sources
- ZeroSettle iMessage API docs: "Sign in with iMessage", "Read messages", "Webhooks, step by step", "AI agent" and "Errors, limits, and compliance". Checked October 8, 2026.
- ZeroSettle homepage: Plans. Checked October 8, 2026.
- Firebase pricing, Google: Phone Auth "Billed per SMS sent". Checked October 8, 2026.
- Create Custom Tokens, Firebase Authentication documentation. Checked October 8, 2026.
- Send SMS Hook, Supabase Auth documentation. Checked October 8, 2026.
- Passwordless Authentication with SMS, Auth0 documentation: "Auth0 supports using Twilio and a custom phone provider to send OTPs." Checked October 9, 2026.
- createSignInToken(), Clerk Backend SDK reference. Checked October 8, 2026.
- NIST SP 800-63B, Authentication and Authenticator Management, National Institute of Standards and Technology: section 3.1.3 (out-of-band devices) and section 2.3.2 (AAL3). Checked October 8, 2026.