Skip to content

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 ​

text
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: ASWebAuthenticationSession on iOS, Custom Tabs on Android.
  • Google returns the ID token in the URL fragment (#id_token=...). A Web application client accepts only https redirect 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.

html
<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 nonce in 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.

SettingValue
Application typeWeb application
Authorized redirect URIsThe relay page, for example https://app.example.com/zklogin/callback
Authorized JavaScript originsOnly 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 different aud is a different address for the same user. See the client ID rule.
  • If you use a native Google sign-in SDK, read the aud of 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:

text
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 Origin header 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/address and /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: 429 and 503 tell the app to retry, 4xx tells it to stop.
  • Copy the Retry-After header to your response. It is set on 429 PROOF_RATE_LIMITED, 503 PROVER_BUSY, 503 SALT_UNAVAILABLE, and 503 CHAIN_UNAVAILABLE.
  • Forward the code field 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 maxEpoch against 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.
  • maxEpoch is 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.

PlatformWhere
iOSKeychain, kSecAttrAccessibleWhenUnlockedThisDeviceOnly. Add biometric access control if you want a prompt for each signature.
AndroidEncrypt 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 ​

  1. On launch, load the session and read the current epoch from the key's network.
  2. If the epoch is past maxEpoch, delete the session and start a new login.
  3. If the session ends within one epoch, ask for a new login at a quiet moment, not in the middle of a payment.
  4. 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.

InputValue
Ephemeral secret keysuiprivkey1qqqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszasa5uj
extendedEphemeralPublicKeyAIqI4910CfGV/VLbLTy6XXLKZwm/HZQSG/N0iAG0D29c
maxEpoch100
jwtRandomness100681567828351849884072155819400689117
Expected nonce8PztBWREkXMKbI_Syjdz2DDvhZU
InputValue
isshttps://accounts.google.com
audexample.apps.googleusercontent.com
sub110463452167303598383
salt129390038577185583942388216820280642146
Expected seed9628841533719198321297179929717386613423422173499892040805405538092987283960
Expected address0x40ee11a271e1179399f37707a6f46074c9896b66272e4e571c740867d94c49a0

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.

  1. Nonce. The nonce claim in the ID token equals the nonce your app computed.
  2. Address. The address from the API equals jwtToAddress(jwt, salt, false) with the salt from the same response.
  3. Stable address. A second login of the same user returns the same address, on every platform you ship.
  4. Proof. The addressSeed from prove equals the addressSeed from address.
  5. Signature. Sign a personal message with the ephemeral key, wrap it with getZkLoginSignature, and check it with verifyPersonalMessageSignature(message, signature, { client, address }). The Sui node does the check, so this proves the full chain without a transaction or gas.
  6. Restart. Close the app, open it, and sign again without a new login.
  7. Expiry. Set maxEpoch to the current epoch, wait for the epoch to change, and confirm the app asks for a new login.
  8. Retry. Make your backend return 503 with Retry-After, and confirm the app waits and tries again.

The full-stack Sui data layer.