Native iOS and Android
The quickstart runs in a browser. A native app differs in four places: the OAuth return path, where the API key lives, where the session key is stored, and how you test. The two API calls are the same.
The shape that works
app -> system browser -> Google -> your HTTPS relay page -> app deep link
app -> your backend -> Inodra (address, prove)- The app never calls Inodra directly. Your backend holds the API key.
- The app opens Google in the system browser:
ASWebAuthenticationSessionon iOS, Custom Tabs on Android. - Google returns the ID token in the URL fragment (
#id_token=...). A Web application client accepts onlyhttpsredirect URIs, so Google cannot redirect to your app's scheme. It redirects to a small HTTPS page you host. - A fragment never reaches a server. The page reads it in JavaScript and opens your app's deep link with the token.
The relay page
A static page is enough. It must do one thing: move the fragment into your deep link.
<script>
// https://app.example.com/zklogin/callback
const params = new URLSearchParams(location.hash.slice(1))
const idToken = params.get('id_token')
location.replace('myapp://zklogin?id_token=' + encodeURIComponent(idToken ?? ''))
</script>- Serve it over HTTPS. Do not log the URL: the token is in it until the script runs.
- Prefer a universal link (iOS) or an app link (Android) to a custom scheme. Another app can claim a custom scheme.
- The app must check that the
noncein the token is the nonce it created. Drop the token if it is not.
Google Console setup
Use one OAuth client of type Web application for web, iOS, and Android.
| Setting | Value |
|---|---|
| Application type | Web application |
| Authorized redirect URIs | The relay page, for example https://app.example.com/zklogin/callback |
| Authorized JavaScript origins | Only if a web app also uses Google's JavaScript library |
- The app's deep link (
myapp://...) is not registered with Google. It is the second hop, from your relay page. - Do not create an iOS client or an Android client for zkLogin. Each has its own client ID. A different client ID is a different
aud, and a differentaudis a different address for the same user. See the client ID rule. - If you use a native Google sign-in SDK, read the
audof the token it returns. If it is not your shared Web client ID, the user gets a different address than on your other platforms. - The client secret is never used. zkLogin uses the ID token only.
- Add the Web client ID on the dashboard zkLogin page.
Build the sign-in URL the same way as the quickstart, with redirect_uri set to the relay page:
https://accounts.google.com/o/oauth2/v2/auth
?client_id=<web client id>
&redirect_uri=https://app.example.com/zklogin/callback
&response_type=id_token
&scope=openid
&nonce=<nonce from generateNonce>Keep the API key on your server
A mobile binary cannot keep a secret. Anyone can extract a key from an app package.
- Do not ship the Inodra API key in the app. Allowed origins protect a browser key, because a browser sends an
Originheader that a page cannot forge. A native client can send any header, so origins do not protect it. - Put the key in your backend. Expose two routes of your own to the app, for example
/wallet/zklogin/addressand/wallet/zklogin/prove, and authenticate the app's user on them as you do for the rest of your API. - Give the key only the zkLogin scope.
- Make the Inodra base URL a server-side setting. You can then point a staging build at a different environment without a new app release.
- Do not log request bodies on these routes. They contain the user's JWT.
Forward the retry signal
Your backend is now a proxy, and a proxy drops headers unless you copy them.
- Forward the status code as it is:
429and503tell the app to retry,4xxtells it to stop. - Copy the
Retry-Afterheader to your response. It is set on429 PROOF_RATE_LIMITED,503 PROVER_BUSY,503 SALT_UNAVAILABLE, and503 CHAIN_UNAVAILABLE. - Forward the
codefield of the error body. The app needs it to tell "log in again" from "try again".
Which network the API checks
The API key belongs to a project, and the project has a network.
- Prove checks
maxEpochagainst the current epoch of the key's network. - A new organization's default project is on mainnet.
- Read the epoch from the same network as the key. Mainnet and testnet epochs differ by tens of epochs, so a testnet epoch with a mainnet key gives
400 MAX_EPOCH_OUT_OF_RANGE. maxEpochis part of the nonce, and the nonce is in the token. After a wrong epoch the user must sign in again.
Store the session on the device
A session is the ephemeral secret key, the proof, maxEpoch, and the address. The ephemeral key signs every transaction until maxEpoch, so treat it as a wallet key for that time.
| Platform | Where |
|---|---|
| iOS | Keychain, kSecAttrAccessibleWhenUnlockedThisDeviceOnly. Add biometric access control if you want a prompt for each signature. |
| Android | Encrypt the session with a key held in the Android Keystore. Set user authentication on that key for a biometric prompt. |
- Do not put the session in
UserDefaults,SharedPreferences, or a plain file. Exclude it from cloud backup. - The proof and the address are not secret. You can keep them with the key for simplicity.
- Never send the ephemeral secret key to your backend. Your backend needs only the extended public key for prove.
App restarts and session end
- On launch, load the session and read the current epoch from the key's network.
- If the epoch is past
maxEpoch, delete the session and start a new login. - If the session ends within one epoch, ask for a new login at a quiet moment, not in the middle of a payment.
- A new login makes a new ephemeral key, a new nonce, and a new proof. The address stays the same.
One proof costs 50 credits and lasts until maxEpoch. Do not request a proof on every launch.
Test your integration
Offline test vector
These values come from @mysten/sui 2.x. Your code must produce the same nonce, address seed, and address before you call any API.
| Input | Value |
|---|---|
| Ephemeral secret key | suiprivkey1qqqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszasa5uj |
extendedEphemeralPublicKey | AIqI4910CfGV/VLbLTy6XXLKZwm/HZQSG/N0iAG0D29c |
maxEpoch | 100 |
jwtRandomness | 100681567828351849884072155819400689117 |
| Expected nonce | 8PztBWREkXMKbI_Syjdz2DDvhZU |
| Input | Value |
|---|---|
iss | https://accounts.google.com |
aud | example.apps.googleusercontent.com |
sub | 110463452167303598383 |
salt | 129390038577185583942388216820280642146 |
| Expected seed | 9628841533719198321297179929717386613423422173499892040805405538092987283960 |
| Expected address | 0x40ee11a271e1179399f37707a6f46074c9896b66272e4e571c740867d94c49a0 |
The secret key above is public. Never use it for a real session. The salt is an example, not one of yours.
End-to-end checklist
Run this once on each platform, with a real login, against a testnet or mainnet key.
- Nonce. The
nonceclaim in the ID token equals the nonce your app computed. - Address. The address from the API equals
jwtToAddress(jwt, salt, false)with thesaltfrom the same response. - Stable address. A second login of the same user returns the same address, on every platform you ship.
- Proof. The
addressSeedfrom prove equals theaddressSeedfrom address. - Signature. Sign a personal message with the ephemeral key, wrap it with
getZkLoginSignature, and check it withverifyPersonalMessageSignature(message, signature, { client, address }). The Sui node does the check, so this proves the full chain without a transaction or gas. - Restart. Close the app, open it, and sign again without a new login.
- Expiry. Set
maxEpochto the current epoch, wait for the epoch to change, and confirm the app asks for a new login. - Retry. Make your backend return
503withRetry-After, and confirm the app waits and tries again.