---
title: Mobile apps
description: Sign-in from iOS and Android apps, with universal links, app links and PKCE.
---

Native apps sign in the way the OAuth standard for native apps
([RFC 8252](https://www.rfc-editor.org/rfc/rfc8252)) recommends. The app
opens ZevAuth in the phone's own browser sign-in sheet, and the person
comes back to the app with a one-time code that only that app can
redeem.

That sheet is `ASWebAuthenticationSession` on iOS and Chrome Custom
Tabs on Android. Never use an embedded WebView: the person can't see the
address bar, and the app could read what they type.

> **Note:**
> Building with Expo or React Native? [`@zevauth/expo`](/sdk/expo/) does all of
> this for you, including the sign-in sheet, PKCE, the state check and
> finishing the link when the app opens. This page describes what happens
> underneath, and what to register in the console.

## How a sign-in works

1. Your app creates a random **code verifier** and a **state**, and keeps
   both in memory.
2. It opens `/v1/auth/zevid/start` in the system sign-in sheet with:
   - `redirect_uri`: your app's link;
   - `state`;
   - `code_challenge`: the SHA-256 of the verifier, base64url-encoded;
   - `code_challenge_method=S256`.
3. The person signs in. If they're already signed in to ZevID in Safari
   or Chrome, they're recognised.
4. ZevAuth sends them back to your link with `code` and your `state`.
   Check the state matches what you sent.
5. Your app exchanges the code at `POST /v1/auth/handoff/exchange` with
   `codeVerifier` and the same `redirectUri`, and receives a session.

```http
POST /v1/auth/handoff/exchange
Authorization: Bearer pk_live_…

{ "code": "hoc_…", "codeVerifier": "…", "redirectUri": "https://acme.com/app/callback" }
```

The code works once and expires after 60 seconds. If someone else
intercepts the redirect, the code is still useless to them without your
app's verifier.

Errors come back to the same link with `error`, `error_description` and
your `state`. Tokens never appear in a URL.

> **Note:**
> Leave `prefersEphemeralWebBrowserSession` off on iOS. With it on, people
> who are already signed in to ZevID in Safari have to sign in again.

The JavaScript SDK does all of this in a browser too:
`client.signIn.withZevId({ pkce: true })` binds the sign-in to the tab
that started it.

## Where a sign-in may return

| Link | Development | Production |
| --- | --- | --- |
| Universal link (iOS) or app link (Android) on your domain | Allowed | Allowed once your domain's association file lists your app |
| Custom URL scheme, such as `com.acme.app:/callback` | Allowed, with PKCE | Not allowed, unless ZevAuth support enables it for your app |
| `http://localhost` | Allowed | Not allowed |

Any app on a phone can claim a custom scheme, so a scheme proves nothing
about which app opens it. A universal link or app link does, because the
phone checks your domain's association file before opening your app.

### Registering your app

1. Verify your project domain under **Project settings → Domains &
   callbacks**.
2. Register the link itself as a callback, for example
   `https://acme.com/app/callback`.
3. Open **Project settings → Mobile apps** and choose **Add app**:
   - **iOS:** the bundle ID (in Xcode: your target → General → Bundle
     Identifier) and your Apple team ID (your Apple developer account →
     Membership details).
   - **Android:** the package name (the `applicationId` in
     `build.gradle`) and the SHA-256 fingerprint of each signing
     certificate. If you publish with Play App Signing, add the app
     signing key's fingerprint from Play Console → your app → Test and
     release → App integrity → App signing, because Google signs what
     people install. For your own key or a development build, run
     `keytool -list -v -keystore your.keystore -alias your-alias`. Paste
     fingerprints in any format; the console fixes colons and case.
4. Publish the association file on the link domain. The **Association
   files** panel on the same page generates it from your apps, ready to
   copy:
   - **iOS:** `https://acme.com/.well-known/apple-app-site-association`,
     served as `application/json` with no file extension.
   - **Android:** `https://acme.com/.well-known/assetlinks.json`.

   Serve them directly, without a redirect: phones don't follow one.
5. Press **Check again**. The app shows **Link verified** once the file
   lists it.

We read the file when you add the app, when you press **Check again**,
and once a day after that, so a file that changes or disappears is
noticed. If the app isn't listed, the console says exactly what was
missing.

#### Limits

| | Per environment |
| --- | --- |
| iOS apps | 10 |
| Android apps | 10 |
| Signing certificates per Android app | 5 |
| Apple teams | 1: all iOS apps in an environment belong to the same team |

### What people see when they sign in with ZevID

Once an app's link is verified, "Sign in with ZevID" knows about it. On a
phone that matches a verified app, the ZevID consent screen names your
app with the device, such as "Acme on iPhone" or "Acme on Android", so
people know exactly where they are signing in. Until then it shows your
app's name alone. Sign-in works either way.

The **Shown in Sign in with ZevID** status on the Mobile apps page says
whether your verified apps are up to date there. If an identifier is
ever not accepted, the page says which one and why. We retry
automatically; contact ZevAuth support if it persists.

## How long a ZevID session lasts

A session that started with "Sign in with ZevID" needs a fresh ZevID
sign-in after at most **90 days**, or after **30 days** without being
used, on every platform. You can shorten both for your environment under
**Project settings → Mobile apps → Sign in with ZevID sessions**. You
can't make them longer. These are today's limits; if they are ever
tightened, sessions follow the new limits automatically.

Each new ZevID sign-in also asks ZevID for a sign-in no older than that
window, so a session never starts already past it.

When a window passes, refreshing the session fails with
`reauthentication_required`. The SDK signs the person out and shows
"For your security, please sign in with ZevID again".

A ZevID session also ends straight away, on the web and in your apps,
when the person:

- removes your app from their ZevID account;
- signs out of the ZevID session your app's sign-in came from (only the
  sessions made from it end; their other devices stay signed in);
- signs out of ZevID everywhere, changes their ZevID password, or has
  their ZevID account closed (every ZevID session in every app ends).

Sessions from "Sign in with ZevID" are also checked against ZevID at
least once a day, so a sign-out is honoured even if a notification was
missed.

## Errors

| Status | Code | Means |
| --- | --- | --- |
| 400 | `pkce_method_unsupported` | `code_challenge_method` must be `S256`. |
| 400 | `invalid_code_challenge` | The challenge must be the base64url SHA-256 of the verifier. |
| 400 | `pkce_required` | A custom URL scheme needs PKCE. |
| 400 | `invalid_state` | `state` must be 1 to 512 characters. |
| 403 | `native_redirect_not_verified` | In production, the link's domain doesn't list any of your registered apps yet. |
| 403 | `custom_scheme_not_allowed` | Custom URL schemes are for development unless ZevAuth support has enabled them for your app. |
| 400 | `pkce_verification_failed` | The verifier doesn't match the challenge the sign-in started with. |
| 400 | `redirect_uri_required` | A PKCE sign-in must send the same `redirectUri` to the exchange. |
| 400 | `redirect_uri_mismatch` | The code was issued for a different `redirectUri`. |
| 401 | `reauthentication_required` | A ZevID session needs a fresh ZevID sign-in. |
| 400 | `native_app_limit` | The environment already has 10 apps for that platform, or the app has more than 5 certificates. |
| 400 | `native_app_team_mismatch` | Your other iOS apps belong to a different Apple team. |
