SenseCrypt Docs
SDKs & appsMobile SDK

In-app sign-in (first-party)

Sign a user in to your own mobile app with a face scan inside the app, with no browser, and get IdP-issued DPoP-bound tokens. The SignInFlow, the console opt-in it needs, how to call your APIs with the tokens, and how the session is refreshed.

In-app sign-in lets a tenant's own mobile app sign its user in inside the app: the user taps Sign in, does a face scan, and the app holds ordinary IdP-issued tokens. No browser opens, no QR is scanned, and no second app is involved. The app calls one SDK method, startSignInFlow, and observes its state.

It is meant for an app that is itself the product: a banking or field-service app whose own backend is a resource server checking SenseCrypt access tokens. The flow follows the IETF draft OAuth 2.0 for First-Party Applications (draft-ietf-oauth-first-party-apps): the IdP's first-party challenge endpoint opens a session, the phone runs the same face ceremony it runs for a CIBA or QR sign-in, and the app redeems an authorization code at the token endpoint. The SDK handles each of these calls itself, so your app never touches a key, code, PKCE verifier, DPoP proof or nonce.

Only a tenant running its own app can use it. In-app sign-in is off by default and is turned on per application. The IdP offers it only in a tenant whose mobile app is its own build (Ship your own Authenticator app), never to the stock SenseCrypt Authenticator. It needs an SDK build that includes SignInFlow (device protocol 8 or later).

How it differs from the login flow

Login flowIn-app sign-in
Who starts itA relying party, through a browser redirect or a CIBA requestYour app, when the user taps Sign in
Input your app suppliesThe sign-in link from a QR or a pushNothing but the config. An optional loginHint
Who gets the tokensThe relying partyYour app, as a stored DPoP-bound session
BrowserOn the relying party's sideNone. There is no browser fallback

What the SDK does

The SDK makes four calls. Your app sees only the state machine further down.

1. Open a sign-in session (DPoP proof, PKCE, login hint) Session handle and device session 2. Fetch the sealed face token (device-key signed, attested) Face token 3. Deliver the face proof (device-key signed, same DPoP key) Authorization code 4. Redeem the code (PKCE verifier, DPoP proof) Access token, ID token, refresh token with offline_access Liveness, face match, next face token minted Your app + SDK SenseCrypt

Step 2 and the face check are the same signed device ceremony the login flow runs for a CIBA or QR sign-in. App attestation, liveness, matching, the budget of three face mismatches, and the rotation of the face token do not change. The IdP binds the session to the DPoP key that opened it and to the enrolled device, so a session relayed to another phone cannot be completed there.

Before you start: turn it on in the console

Three settings must hold. The IdP checks all three on every call, so if any of them is withdrawn the feature stops at once.

  1. Your tenant runs its own app. On the console's Mobile App page, the app source is your own app, with its identity registered. See Ship your own Authenticator app.
  2. The application issues DPoP-bound tokens. On the application's Tokens & Security tab, turn on Require DPoP proofs and leave Bind tokens to the client certificate off. In-app sign-in binds tokens to a key on the phone, so a certificate binding cannot apply.
  3. The opt-in. On the same tab, turn on Allow in-app sign-in from your mobile app. Through the Management API it is the application's first_party_challenge_enabled field.

Like every OIDC application, this one must register at least one redirect URI, although the flow never redirects. Your app link does the job.

If a save breaks one of these rules, it is refused with the rule's code:

Rule codeFix
first_party.requires_dpopTurn on DPoP-bound access tokens first.
first_party.no_certificate_bindingRemove the client-certificate binding.
first_party.requires_custom_appSwitch the tenant to its own app on the Mobile App page.

The application's self-signup setting can be on or off. With it off, users must be added by an administrator or through SCIM, and then enroll on the phone. With it on, new users can sign up in your app through the registration flow.

Once the opt-in is on, every token request for this application needs a DPoP server nonce. This covers browser sign-ins from a website that shares the application and every refresh, not only the requests from your app. A web front end's DPoP library must retry when the IdP answers use_dpop_nonce (RFC 9449 §8). See Sender-constrained tokens.

Attestation: run enforce in production

Your tenant's app-attestation mode applies to this flow as it does to every other device flow. The opt-in does not require enforce, because a development build that is not distributed through the stores cannot pass Play Integrity, and you need to test before release. Even without attestation, an account stays protected: the sign-in still needs the enrolled device key, a live face match, and the app's own DPoP key and PKCE verifier.

What attestation adds is proof of which app opened the session. With it off, a non-genuine app that knows your public client_id can open sign-in sessions that cannot be completed but that still use up the rate limits below. Test in audit, which verifies and logs without blocking, and ship with enforce. The Config Doctor, in the application's Connect your app panel on the console, warns while an opted-in application's tenant is not at enforce.

Sign up or sign in

The SDK decides from local state. If this device holds no enrolled key at all, the flow ends at once in failed(.notEnrolled), before any call. With a loginHint for an address this device has no key for, the SDK still makes the start call and the face-token fetch, then ends the same way. Either way, run the registration flow and then start the sign-in again.

The IdP never tells your app whether an email is enrolled. It answers every start identically, whoever the hint names, and a hint that cannot sign in gets a session that can never complete. That session also ends in notEnrolled. This keeps account existence private: your app cannot use the endpoint to find out who has an account.

An account whose face token is password-protected (an administrator imported it with a password) cannot use in-app sign-in: the flow has no password prompt. It aborts the IdP session and ends failed(.server) with a message that says so. Self-enrolled accounts never carry a password.

Starting the flow

Build a SignInConfig and start the flow when the user taps Sign in. The flow starts at once.

let config = SignInConfig(
    issuer: "https://acme.idp.example",   // the tenant's issuer
    clientId: "app_…",                    // the opted-in application
    resource: "https://api.acme.example", // your API's audience, or nil
    scopes: ["openid", "email"],          // empty means openid
    loginHint: nil,                       // nil: the key enrolled for this app
    pki: nil
)
let flow = authn.startSignInFlow(config: config)

Task {
    for await state in flow.stateStream() {
        handle(state)
    }
}
val config = SignInConfig(
    issuer = "https://acme.idp.example",   // the tenant's issuer
    clientId = "app_…",                    // the opted-in application
    resource = "https://api.acme.example", // your API's audience, or null
    scopes = listOf("openid", "email"),    // empty means openid
    loginHint = null,                      // null: the key enrolled for this app
    pki = null,
)
val flow = authn.startSignInFlow(config)

lifecycleScope.launch {
    flow.state.collect { state -> handle(state) }
}

SignInFlow.state is a SharedFlow (replay 1), like LoginFlow's. Read currentState for a synchronous value.

SignInConfig fieldMeaning
issuerThe tenant's issuer. Must be https://. http:// is accepted only for a loopback host, for local development. Any other value fails at once with Server. Scheme and host are compared case-insensitively.
clientIdThe application with in-app sign-in turned on.
resourceThe API audience (RFC 8707) the access token is for. nil for the IdP's default. It must be an API the application is granted.
scopesThe scopes to request. Empty means openid. Include openid to receive an ID token. Add offline_access for a session that outlives its access token: the SDK stores the refresh token and renews the access token without a face scan (see Session length and refresh). Without it, the token expires and the user signs in again.
loginHintThe account's email. nil uses the key enrolled for this app, or the most recent key.
pkiOptional. A face-PKI operation to run during the sign-in. See Face-PKI operations.

The state machine

submitFaceCapture liveness failed / face mismatch (retry) transient retry token response lost attempts spent refused cancel cancel Idle Starting AwaitingFaceCapture Processing PostingResult ExchangingCode PostingResultFailed SignedIn Failed
StatePayloadWhat your UI does
idle / startingShow a short progress indicator while the IdP session opens.
awaitingFaceCapturelastError: FaceCaptureRetryReason?Show the face-capture screen and drive it with faceCaptureDriver(). On a non-nil lastError, show the matching message first.
processingOn-device work: liveness, face match, minting the next face token.
postingResultThe face proof is on its way to the IdP.
postingResultFailedreason: SignInFailureNot terminal. Offer Try again → retry(). It re-sends the same proof, with no new face scan.
exchangingCodeThe SDK is redeeming the code for tokens.
signedInexpiresAtEpochSecs, session: TokenSession, pki: PkiResult?Terminal success. The session is stored and ready for API calls.
failedreason: SignInFailureTerminal. See Failures.

The face-capture step

The capture step is the same as the login flow's, with the same budget of three face mismatches. A liveness failure does not count against it. Show the bundled capture surface and hand it the flow's driver. See UI components.

let driver = flow.faceCaptureDriver()
val driver = flow.faceCaptureDriver()

The driver's Try again sheet calls the flow's retry() for you after a postingResultFailed.

Controls

MemberPurpose
faceCaptureDriver()The capture driver for the bundled view.
submitFaceCapture(captureSession:)Submit a passing capture directly. Prefer the driver.
retry()After postingResultFailed: re-send the captured proof, then exchange the code. A no-op in any other state.
cancel()Ends failed(.cancelled) at once. If the IdP session is already open, it is aborted in the background.
currentState() / currentStateSynchronous state read.

Network failures cost no second face scan

The SDK retries lost requests on its own. A lost start is sent once more. A lost face-token fetch is re-run on the same session. A lost completion is re-sent once, and after that, or on a 5xx or 429, the flow parks in postingResultFailed holding the exact proof. If the token response is lost, the SDK re-sends the completion (up to twice) and the IdP answers with the code again. If the lost exchange had already redeemed the code, the IdP issues a replacement and revokes whatever the lost exchange issued, so one sign-in never leaves two live grants. The IdP issues at most three replacement codes per session, and only within the session's lifetime. Only the parked case needs your UI, through retry().

Using the tokens

On signedIn, the SDK stores one session per install in the platform's secure storage: the access token, the checked ID-token claims, and, when the sign-in asked for offline_access, the refresh token. The access token is DPoP-bound (token_type DPoP, with a cnf.jkt claim) to a P-256 key the SDK generated in the device's secure hardware (StrongBox-backed Keystore on Android, Secure Enclave on iOS), one per install. A copy of the token is useless without that key, and the session is never synced off the device. The refresh token never leaves the SDK and is never logged.

Use the platform helper to call your own APIs. It adds Authorization: DPoP <token> and a fresh DPoP proof to every request, for every HTTP method, and keeps the session alive with its refresh token.

let request = URLRequest(url: URL(string: "https://api.acme.example/accounts")!)
let (data, response) = try await SenseCryptDPoP.data(for: request)
if (response as? HTTPURLResponse)?.statusCode == 401 {
    // no session, or the IdP ended it: run the sign-in again
}

SenseCryptDPoP.data(for:) wraps URLSession. Pass tokenSession: to use a specific session, and session: for your own URLSession.

val http = OkHttpClient.Builder()
    .addInterceptor(authn.dpopInterceptor())   // an application interceptor
    .build()

Add it with addInterceptor, not addNetworkInterceptor, because it may send a request twice. OkHttp is not bundled with the SDK. Your app supplies it, and Retrofit uses it.

What the helpers do:

  • No session (never signed in, expired with no refresh token, or signed out): the request goes out unchanged, so your API's 401 tells the app to sign in again.
  • Refresh before the request. When the session holds a refresh token and its access token has expired or expires within 30 seconds (needsRefresh()), the helper refreshes it first, off the UI thread and with no face scan. A refresh that ends the session (SignInRequired: the IdP revoked it, the family expired, or the user lost access) sends the request unauthenticated, so your API's 401 drives the sign-in as it does with no session. A refresh the IdP could not serve right now (Network, Server) proceeds while the token is still valid; once it has expired, the call fails like a lost connection (an IOException on Android, a thrown error on iOS). The app may try again and is not told to sign in.
  • Nonces: every DPoP-Nonce a response carries is recorded for that origin and used in the next proof.
  • One retry on a nonce challenge. A request is repeated exactly once when the API answers 401 with WWW-Authenticate: DPoP error="use_dpop_nonce" and a fresh nonce (RFC 9449 §9). Never for a streamed or one-shot body.
  • Refresh after invalid_token. A 401 whose WWW-Authenticate names error="invalid_token" (your API found the token expired or revoked before the helper did) refreshes the session once and repeats the request once, with its own nonce retry; replayable bodies only. The repeat's answer is returned as is, whatever it says. Any other 401 or 403 is returned untouched: that is your API's own verdict.

A retried POST is safe only if your API checks DPoP before any side effect. RFC 9449 resource servers do. If yours does not, a nonce challenge after a side effect would repeat it.

For a custom HTTP stack, run the same loop on the session yourself: refresh() first when needsRefresh() says so; authorize(method:url:) once per attempt to get the headers; observeResponse(url:status:wwwAuthenticate:dpopNonce:) after each response, which returns true when the request should be repeated once for a nonce; and shouldRefreshAfter(status:wwwAuthenticate:), which returns true when the response was an invalid_token refusal, so you refresh once and repeat once.

Session memberPurpose
authenticator.currentSession()The stored session, or nil when there is none, its DPoP key is gone or changed, or its access token has expired and it holds no refresh token. A session whose access token has expired but that holds a refresh token is returned: accessTokenExpired() is then true and refresh() renews it.
TokenSession.accessToken()The raw access token. Prefer authorize, which also mints the proof the token is useless without. Check needsRefresh() first: an expired token is returned as is.
TokenSession.idTokenClaimsJson()The validated ID token's claims (sub, iss, aud, …) as JSON.
TokenSession.expiresAtEpochSecs()Absolute expiry of the current access token, in Unix seconds.
TokenSession.canRefresh()Whether the session holds a refresh token.
TokenSession.accessTokenExpired()Whether the access token has expired.
TokenSession.needsRefresh()Whether to call refresh() before the next request: the session can refresh, and the access token has expired or expires within 30 seconds.
TokenSession.refresh()Renews the access token in place. Blocks for the round trip, so never call it on the UI thread. One refresh runs at a time per install; a concurrent caller waits and shares the result. Throws RefreshError (Swift) / RefreshException (Kotlin); see Refresh errors.
TokenSession.authorize(method:url:)Headers for one request. A proof is single-use, so call it for every attempt.
TokenSession.observeResponse(...)Records the response's nonce; says whether to repeat the request once.
TokenSession.shouldRefreshAfter(status:wwwAuthenticate:)Whether the response was a 401 with error="invalid_token", so you should refresh once and repeat once. Never true for the nonce challenge or for any other error.
authenticator.signOut()Forgets the stored tokens, revokes the refresh token at the IdP (best effort, in the background), and drops the install's DPoP key. A new key is made at the next sign-in. Enrolled identities are not touched.

The SDK checks the ID token's iss, aud (and azp when it names several audiences), nonce, exp and iat before it stores the session. It does not verify the ID token's signature: the token arrives straight from the issuer's token endpoint over TLS, which OIDC Core §3.1.3.7 allows a client to rely on instead. That is why issuer must be https://. alg: none and HMAC algorithms are refused regardless.

Session length and refresh

The access-token lifetime is the API's token lifetime, set on its resource server, or else the deployment-wide default. What happens when it runs out depends on the scopes the sign-in asked for.

Without offline_access the session ends with its access token. currentSession() returns nil, and the user signs in again with a face scan.

With offline_access the IdP also issues a refresh token, which the SDK stores with the session and never hands to your app. The helpers, or your own call to refresh(), then renew the access token without a face scan until the IdP ends the session:

  • The refresh is the refresh_token grant at the issuer's token endpoint, with a DPoP proof from the same install key and the IdP's server nonce. The IdP binds a public client's refresh token to that key, so a copy of the refresh token is as useless as a copy of the access token.
  • One refresh runs at a time per install. Concurrent callers wait for it and share its result, so a spent token is never presented twice. The successor is stored before the new access token can be used.
  • invalid_grant ends the session: the family was revoked or expired, the token was re-used, or the user lost access to the application. The stored tokens are cleared, the DPoP key and the enrolled identities stay, and the app runs the sign-in again. This is SignInRequired to a custom stack, and an unauthenticated request to the helpers.
  • A refreshed ID token carries no nonce. The SDK checks it as before and also checks that its sub matches the stored claims.
  • A new sign-in replaces a stored session and revokes that session's refresh token at the IdP, best effort, as signOut() does.

How long a refresh token lives, whether it rotates, and what a re-used token does are the IdP's policy for the application. See Refresh tokens and sessions.

What your API checks

Your backend validates the token as a DPoP-bound access token: the signature against the tenant's JWKS, iss, aud (your API's audience), exp, and a DPoP proof whose key thumbprint matches cnf.jkt, with ath matching the token. A token that carries cnf.jkt but arrives under the Bearer scheme must be refused; that check is what the binding rests on. If you use DPoP nonces, answer a missing or stale nonce with the use_dpop_nonce challenge above. See Sender-constrained tokens.

Confidential clients and FAPI 2.0 tenants

A public client sends only its client_id, which is the usual set-up for a mobile app. In a tenant created with the FAPI 2.0 profile, every application is confidential, so the in-app application is a private_key_jwt client. Its private key must never ship in the app. It stays on your backend, and the app fetches a fresh client assertion from that backend for every request.

Start the flow with a ClientAssertionProvider:

final class BackendAssertions: ClientAssertionProvider {
    func clientAssertion(request: ClientAssertionRequest) throws -> String {
        // Ask your backend for a fresh assertion. Blocking is fine:
        // this runs on an SDK worker thread.
        guard let jwt = try? myBackend.mintAssertion(
            clientId: request.clientId,
            audience: request.audience,   // the issuer
            dpopJkt: request.dpopJkt      // this install's DPoP key
        ) else {
            throw ClientAssertionError.Unavailable(details: "backend unreachable")
        }
        return jwt
    }
}

let flow = authn.startSignInFlow(config: config, clientAssertionProvider: BackendAssertions())
class BackendAssertions : ClientAssertionProvider {
    override fun clientAssertion(request: ClientAssertionRequest): String =
        // Ask your backend for a fresh assertion. Blocking is fine:
        // this runs on an SDK worker thread.
        myBackend.mintAssertion(request.clientId, request.audience, request.dpopJkt)
            ?: throw ClientAssertionException.Unavailable("backend unreachable")
}

val flow = authn.startSignInFlow(config, BackendAssertions())

The provider is called once for every HTTP attempt to the challenge and token endpoints, retries included. Each assertion must follow the FAPI rules:

  • iss and sub are the client_id.
  • aud is the issuer identifier as a string, which is what request.audience holds. The token endpoint URL or an array is refused with invalid_client.
  • It is signed with ES256 or PS256 by a key registered on the application.
  • It has a short exp and a single-use jti. An assertion cannot be replayed from one call to the next.

request.dpopJkt is this install's DPoP key thumbprint, so your backend can bind, log or rate-limit assertions per install. If no assertion can be had, throw Unavailable. The SDK treats that like a lost request: it re-sends automatically, then reports postingResultFailed or failed(.network). Never log the assertion, and never put a secret in details.

Register the provider at launch

The stored session's refresh and signOut()'s revocation authenticate with the same provider. A provider passed to startSignInFlow is kept for them, but it does not outlive the process, and the stored session does. A confidential client's app therefore registers the provider once at launch, before the first API call:

authn.setClientAssertionProvider(BackendAssertions())
authn.setClientAssertionProvider(BackendAssertions())

clearClientAssertionProvider() forgets it. A public client never needs either call, and starting a public-client sign-in with startSignInFlow(config:) clears any registered provider. Without a provider, a confidential client's refresh goes out unauthenticated; the IdP refuses it with invalid_client, which the SDK reports as Server, and the session is kept as it was.

The rest of the FAPI request rules apply unchanged: authorization codes live 60 seconds, tokens are DPoP-bound, and the completion and token requests need a server nonce. The SDK handles all three. The application's registered redirect URI must be https (or loopback) under the profile; an https app link satisfies both rule sets.

Face-PKI operations during sign-in

In a tenant with Face PKI, the sign-in can also run one key operation on the keys the face scan opens: fetch the public keys, sign a payload hash, or decapsulate a shared secret. It runs after the face match and before the face token is rotated. Set pki on the config:

let pki = SignInPkiRequest(
    kind: .sign,
    context: Data("payments-v1".utf8),  // your own context, up to 255 bytes
    operand: payloadHash,               // 32 or 64 bytes for .sign
    scope: nil                          // nil or .app: this app's keys
)
val pki = SignInPkiRequest(
    kind = PkiCeremonyKind.SIGN,
    context = "payments-v1".toByteArray(), // your own context, up to 255 bytes
    operand = payloadHash,                 // 32 or 64 bytes for SIGN
    scope = null,                          // null or APP: this app's keys
)
kindoperandResult on signedIn.pki.outcome
publicKeysEmptyBoth public keys (ML-DSA and ML-KEM, SPKI DER).
signThe payload hash, 32 or 64 bytes, signed verbatimThe signature and the ML-DSA public key.
decapsulateThe ML-KEM ciphertextThe shared secret and the ML-KEM public key.

The result arrives on signedIn as pki, echoing the context and the scope used, with the public key of the key class it used. Private keys never leave the SDK. A shared secret is one-shot: takeBytes() returns it once and wipes the SDK's copy; later calls return nil.

  • The keys are the ones a relying party's ceremony for the same application and context would use. The IdP does not learn that the operation ran: it gets no binding message and issues no face_pki receipt. If you must tell an in-app signature apart from a ceremony signature, use a context of your own for in-app operations.
  • scope: .tenant uses the keys every allowed application of the tenant shares. It runs only when an administrator allowed the application tenant-wide keys. The SDK enforces this setting, not the server.
  • A PKI sign-in uses only keys the user's face token already holds. If it does not hold them yet, the flow ends pkiKeysNotReady after the face scan, and one plain sign-in fixes it.
  • The refusals before the face scan (pkiNotAvailable, invalidPkiRequest, pkiScopeNotAllowed) abort the IdP session. Nothing was signed in.

Failures

failed(reason:) carries a SignInFailure. None of them leaves a session behind.

SignInFailureMeaningWhat to do
signInUnavailableThe IdP does not offer in-app sign-in to this application: it is not opted in, a required setting was withdrawn, or the IdP predates the feature.Check the console settings. There is no browser fallback.
upgradeRequiredThe IdP needs a newer SDK.Ship an app update.
notEnrolledThis device holds no usable enrollment for the account.Run the registration flow, then sign in again.
faceMismatchThe face did not match within the attempt budget, or the IdP refused the proof.Let the user try again from the start.
livenessFailedThe liveness check failed.As above.
cancelledcancel() was called.Nothing.
network(message)The IdP could not be reached after the automatic retries.Offer to try again.
server(message)The IdP refused a request, the config is invalid (for example a non-https issuer), or the account's face token is password-protected. A message that starts with rate_limited is the IdP's rate limiter refusing the start; it names the seconds to wait.Log message; it is not meant for the user. For rate_limited, tell the user to try again later.
pkiNotAvailableThe tenant has no face PKI. Reported before the face scan.Permanent. Drop the pki request.
pkiKeysNotReadyThe face token does not hold the needed keys yet.Run one plain sign-in, then retry the operation.
invalidPkiRequestThe pki request is malformed: a context over 255 bytes, a wrong-sized hash or ciphertext, or an operand on publicKeys.Fix the request.
pkiScopeNotAllowedTenant-wide keys were asked for, but the application is not allowed them or the account's token is bound to no tenant. Reported before the face scan.Use the app scope, or ask an administrator to allow tenant-wide keys.

Refresh errors

TokenSession.refresh() throws RefreshError (Swift) or RefreshException (Kotlin). The platform helpers handle all three for you; a custom stack sees them directly.

CaseMeaningWhat to do
SignInRequiredThe session is over: it held no refresh token, its DPoP key is gone or changed, the app signed out meanwhile, or the IdP answered invalid_grant. The stored tokens are cleared; the key and the enrolled identities stay.Run the sign-in again.
Network(details)The IdP could not be reached, or did not answer after one re-send. The session is kept as it was.Try again later.
Server(details)The IdP answered with something other than tokens or invalid_grant (a 5xx, a 429, invalid_client, a malformed response), or the successor could not be stored. The session is kept as it was.Try again later. For a confidential client, check that a provider is registered.

Rate limits

The IdP limits sign-in starts per application and email address to 30 an hour, and per tenant to 600 a minute, on top of the per-IP limit every protocol endpoint shares. A call that continues a session already under way never counts against the tenant limit. Thirty starts against one address within an hour lock that address out of in-app sign-in for the rest of the hour. Running attestation in enforce makes this much harder for anyone but your genuine app to cause.

On this page