Troubleshooting
ReadyMost failures are one of a handful of things. Work from the symptom you can see, not the stack trace. Three checks catch the majority; below that, the common symptoms with their real cause and fix. Every server error also carries a typed reason and a trace-id — those two turn a guess into a lookup.
Who this is for: whoever has something not working — an integration that will not connect, a phone that will not enroll, a challenge that never arrives. Organized by what you observe. For the full catalog of typed failure reasons, see Errors.
Start here — three checks#
In that order. They separate “the deployment is unwell” from “this one call is wrong” in under a minute.
ydfi admin health — database pool, certificate validity, background reapers, in one line.
A handshake failure is almost never a code bug. See the first symptom below.
Read ErrorDetail.reason — never the message text — and keep the trace_id.
The typed reason tells you which of the documented failures this is; the trace-id lets your operator find the exact call server-side. Together they replace guesswork. Both are on every error — see Errors.
Nothing connects at all#
What is happening
This is the single most common production failure, and it is not a bug. Ydentifi terminates mutual TLS at the server itself. Anything that terminates TLS in front of it — an application load balancer, Cloud Run, App Gateway, Front Door, a Cloudflare proxy or tunnel, Vercel, Netlify — strips the client certificate. The server then sees a connection with no client identity and fails closed. It does not silently downgrade: you get a failed handshake, not a weaker session.
Fix
Put the deployment behind an L4 / TCP-passthrough load balancer, or none at all — serving directly on :443 is the simplest correct setup. If you need a load balancer, it must pass TCP through untouched. See Deploy for the compatibility matrix.
What is happening
Either the deployment does not recognize your certificate, or it has passed its expiry. Relying-party certificates are long-lived by design (two years by default, up to five), so expiry usually means nobody was watching the date.
Fix
For an unrecognised certificate, have the operator re-provision it. For expiry, rotate: RotateCertificate issues a new one and keeps the old accepted for a grace window so you can hot-reload without dropping connections. Poll GetCertificateExpiry and warn yourself well ahead — there is deliberately no automatic renewal.
The phone cannot reach the deployment#
A phone reaches the local stack across your local network. Three things must hold: the phone and the laptop can see each other, the stack’s ports are published beyond loopback, and the server certificate carries the address the phone dials. Each entry below is one of them.
What is happening
The local stack publishes its ports on loopback only — 127.0.0.1:50057, :50058. Nothing outside the machine can reach them, whatever IP the phone dials. Even after you change that, the server certificate only carries 127.0.0.1 by default, so a phone dialling your LAN address gets a name-mismatch and refuses the handshake. Two separate blockers, and fixing one still leaves the other.
Fix
Do both: publish the ports beyond loopback, and re-issue the certificates with your LAN address as a SAN via ydfi deploy local bootstrap --ip <your-LAN-IP>.
What is happening
Many networks deliberately stop this. Client isolation (also called AP isolation) is standard on guest, hotel, café and a lot of corporate Wi-Fi — every device can reach the internet but not its neighbours. A VPN on either device does the same thing by routing traffic off the local segment, and the macOS firewall will silently drop inbound connections to Docker unless you approved the prompt.
Fix
Use a network that lets devices see each other. To diagnose it: drop the VPN on both devices, check System Settings → Network → Firewall, and try a plain ping from the phone — if that fails, the network is isolating clients and no amount of configuration on your side will change it.
What is happening
Your laptop’s LAN address is handed out by DHCP and changes — after a sleep, a network switch, or a lease expiry. The certificate SAN and the enrollment QR both still point at the old address, so previously-working enrollments and any fresh scan now fail.
Fix
The certificate and the setup codes must name the new address: bootstrap again with it, then enrol again. Reserving the laptop’s address in your router (a DHCP reservation) stops it moving.
What is happening
A captive portal — hotel, airport, conference, guest network. The phone is associated to the access point but every request is being intercepted until someone accepts terms in a browser. The app has no way to show you that page, so it just fails.
Fix
Open any web page on the phone first and complete the portal. This one masquerades as a Ydentifi problem more often than it should.
What is happening
The app and the SDK connect only from a real device. Every connection checks the server against the pin in the setup code, and the simulator’s sandbox refuses the raw sockets the SDK’s transport uses.
Fix
Expected. Test on a real phone, which you need anyway: attestation and the depth-camera factors cannot run in a simulator.
Enrollment does not complete#
What is happening
Enrollment invites are single-use and short-lived — the window is 60 to 600 seconds and defaults to five minutes. A QR left on screen while someone finds their phone will lapse.
Fix
Mint a fresh invite with IssueEnrollmentInvite and raise ttl_seconds if your flow needs longer. Do not reuse a consumed one.
What is happening
Every public action refuses to run on a device showing signs of system modification, and the server independently validates the platform attestation against Apple or Google. A jailbroken or rooted device, or an emulator pretending to be hardware, will not pass.
Fix
Expected behavior, not a defect — use an unmodified device.
What is happening
Someone banned this device’s hardware identifier. A ban targets the hardware, not one enrollment: it deactivates the active enrollment on that hardware, if there is one (at most one per relying party). The phone’s calls and open streams are refused with DEVICE_BANNED. Enrolling the same hardware again is refused with PERMISSION_DENIED “verification_failed” — the same answer a failed attestation gets, so the refusal does not reveal the ban. Your logs tell the two apart: the enrollment is logged as complete_enrollment_blocked_by_ban.
Fix
If the ban was wrong, lift it with UnbanDevice — but note that does not restore the old enrollments. The user re-enrolls from a fresh invite.
Challenges never arrive, or expire first#
What is happening
Challenge lifetime is bounded to 30–300 seconds and defaults to 60. Sixty seconds is tight if the user has to find their phone, unlock it, and read a prompt. Expiry is a status, not an error: AwaitChallenge ends with a final frame whose status is AUTH_CHALLENGE_STATUS_EXPIRED, and a phone that answers afterwards is refused with CHALLENGE_ALREADY_TERMINAL.
Fix
Raise ttl_seconds when you issue — 120 is a comfortable default for a human-in-the-loop action. The expiry is enforced server-side, so the value you send is the value that binds.
What is happening
The identity exists but has no active enrolled device — either it was never enrolled, or every device was revoked, banned, or cascaded away by a recovery invite.
Fix
Check with ListDevicesForUser (it returns inactive rows too, so you can see what happened), then onboard a device. Remember that issuing a recovery invite revokes every active device by design.
What is happening
DEVICE_INCAPABLE means the server refused before prompting: the device physically cannot do what you asked — for example a signed biometric on a handset with no strong biometric enrolled. AUTH_STRENGTH_INSUFFICIENT means it could have at issuance but did not at signing time: Face ID switched off, a wet sensor, a failed biometric.
Fix
For the first, lower min_strength for that action or have the user enroll a capable device. For the second, re-challenge — it is usually transient. Never treat either as a reason to accept a weaker result than you asked for.
What is happening
ALREADY_TERMINAL means it was already approved, denied, or dismissed — terminal states cannot be re-driven. ALREADY_EXISTS is the idempotency signal for a retry after a network loss, not a fault.
Fix
Read the terminal outcome rather than re-issuing. For ALREADY_EXISTS, treat it as success and de-duplicate on your side.
A user is locked out#
What is happening
Two different things, told apart by the reason. USER_LOCKED: the relying party locked this user with UpdateUserStatus, and every call for them is refused until it sets them ACTIVE again. RATE_LIMIT_EXCEEDED: the user emptied a per-user token bucket, and that one action is refused for at least 30 minutes; their other actions still work. The error’s auto_expires_at says when it is admitted again.
Fix
For USER_LOCKED, set the user ACTIVE if the lock is no longer needed. For RATE_LIMIT_EXCEEDED, wait, or clear it deliberately with UnlockUserRequestLimits once you understand why it tripped. If it keeps happening, the relying party’s rate tier may be wrong for your usage — three tiers are selectable per relying party. See Limits & quotas.
Codes, clocks and licenses#
What is happening
Usually the code was already used. Each code is accepted once: a double submit, a retry after a timeout, or two sign-ins within one 30 s step all send a used code. After a newer code is accepted, an older one is refused too. Otherwise, clock drift: TOTP is time-derived, so a device minutes out of step generates codes for the wrong window.
Fix
Never resend a code; ask the user for the next one. For drift, widen the tolerance with the skew parameter on VerifyUserTotp — the default is ±1 step (30 s), and the most is ±2. Fix the underlying clock where you can; widening skew trades a little security for tolerance.
What is happening
The secrets are encrypted with a key the server no longer holds — an operator removed it from the key configuration while secrets still used it.
Fix
If the operator still has the removed key, add its pair back to the configuration next to the pairs set now — keep the newer ones — and restart: every secret is readable again. Otherwise re-mint each affected secret with IssueUserTotpEnrollment(reuse_existing=false). This is a configuration incident, not a code path you can retry around.
What is happening
Either the license expired more than 30 days ago, or you reached the seat count (a free license stops at 50 users). Seat refusals are fail-closed, not throttles.
Fix
Check standing with ydfi license status. The deployment never renews over the network: to renew, run ydfi pull, then ydfi license apply. Requests keep working for 30 days after expiry, then are refused until a new license is installed; see Get a license.
Getting diagnostics#
Four places to look, in increasing depth:
# 1. is the deployment healthy right now? $ ydfi admin health # 2. license seats, usage and expiry $ ydfi license status # 3. metrics for your own monitoring (admin listener) $ curl -s https://your-deployment/metrics
Fourth: the trace-id on any error. It is the one string that lets whoever runs the deployment find the exact call in their tracing — no personal data changes hands, and it turns a support conversation into a lookup. See Operate & maintain for what to monitor day to day.
When something looks wrong, the temptation is to open the database and inspect or correct rows directly. Don’t. Every mutation has safeguards, audit-chain entries, and license accounting attached to it; a direct write bypasses all three and leaves the deployment in a state nothing else expects. Use the CLI and the API — see Operate & maintain.