Native and Mobile Apps

This section documents OAuth 2.0 (RFC 6749) as amended by the OAuth 2.0 Security Best Current Practice (RFC 9700 / BCP 240), the OAuth 1.0 Protocol (RFC 5849) for historical context, and OpenID Connect Core 1.0, as published at the IETF Datatracker and the OpenID Foundation specifications — and, on the Spring pages, Spring Boot 4.1.x and Spring Security 7.1.x as published at the Spring Security reference documentation — which are the references these pages are written and verified against.

OAuth 2.1 is still an Internet-Draft (draft-ietf-oauth-v2-1-16, 3 September 2026) and is flagged as such everywhere it appears on these pages. It is a working-group consolidation in progress, not a published standard; nothing here should be read as saying otherwise.

This content was generated with the assistance of AI and should be verified against those official specifications before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

A native or mobile app is a public client — it ships to a device the app’s own developer does not control at runtime, and it cannot keep a secret. RFC 8252, also published as BCP 212, is the OAuth working group’s Best Current Practice for exactly this client shape, and its central finding is not a set of tuning knobs: it is a flat prohibition on the one design most native apps reached for first, followed by a specific, workable alternative and the reasons that alternative is not just safer but genuinely better for the user.

RFC 8252 §8.12: native apps MUST NOT use embedded user-agents

RFC 8252 §8.12 states this as a normative MUST NOT: native apps must not use an embedded user-agent — commonly an embedded WebView (WKWebView/UIWebView on iOS, WebView on Android) — to render the authorization request. This is not a stylistic preference; it is the single most load-bearing rule on this page, and every recommendation below follows from it.

An embedded WebView is a browser control the hosting app creates, owns, and can fully instrument. Loading the authorization server’s login page inside one breaks three separate security properties at once:

  • The app can read the password. A WebView is just another view inside the app’s own process. The hosting app can inject JavaScript into it, read its DOM, intercept form submissions, or simply log every keystroke — there is no boundary between "the page rendered inside my WebView" and "code I, the app developer, can run against that page". The user believes they are typing a password into the authorization server’s login form; technically, they are typing it into a control the app itself fully controls. This applies whether or not the app’s developer has any intention of misusing it — the point is that the user has no way to verify the intention either way, which is exactly the trust problem OAuth exists to avoid.

  • The cookie jar is not shared, so single sign-on breaks. A real system browser keeps one shared cookie store across every site the user visits in it, which is what lets a user who is already signed in to the authorization server in Safari or Chrome skip the login prompt entirely when a new app requests authorization. An embedded WebView is a separate, app-scoped cookie jar with no visibility into the system browser’s session. Every app that uses an embedded WebView forces the user to log in again, from scratch, no matter how recently they authenticated elsewhere — and, worse, trains users to expect and routinely re-enter their password inside arbitrary in-app screens, which is precisely the habit that makes phishing easier.

  • The user cannot see the real URL or the TLS indicator. A system browser’s address bar is the user’s one reliable way to confirm which domain they are actually typing credentials into and that the connection is genuinely secured. An embedded WebView typically has no address bar at all, or a fake one the hosting app drew itself — either way, a user has no independent way to verify they are talking to as.example.com and not a pixel-perfect phishing page the app chose to load instead.

RFC 8252 §8.12’s prohibition is unconditional: it is not "avoid embedded WebViews where practical" or "prefer the system browser when available". It is a MUST NOT. A native app that renders its authorization request in an embedded WebView is non-compliant with BCP 212 regardless of how the rest of its OAuth integration is built.

The correct choices

RFC 8252 §7 names two acceptable ways to run the authorization request, both of which restore all three properties the embedded WebView breaks:

  • The external browser. The app hands the authorization URL to the operating system, which opens it in the user’s actual default browser app (Safari, Chrome, or whatever the user has chosen) — a fully separate process, with the system browser’s own cookie jar, address bar, and TLS indicator. This is the maximally compliant option, but it is also the most jarring for the user: the app visibly disappears and a different app (the browser) takes over the screen.

  • The in-app browser tab — better for UX. A view that looks embedded in the app but is, underneath, rendered by the system browser’s own engine and process, with the system browser’s cookie jar and security chrome intact. This gets almost all of the external browser’s security properties while keeping the user visually inside the calling app:

    • iOS: ASWebAuthenticationSession — the current API, purpose-built for exactly this: it shares the system browser’s cookies (so SSO works), shows a real address bar and TLS indicator, and is sandboxed away from the calling app’s process, so the app cannot read what happens inside it. It also natively supports the callback-URL completion handler pattern the authorization redirect needs.

    • iOS: SFSafariViewController (older) — the API ASWebAuthenticationSession superseded for authentication use; it shares Safari’s cookie jar and chrome the same way, but lacks the dedicated authentication-session completion callback, so it needs the redirect handled through the app’s own universal-link or custom-scheme handling instead. Prefer ASWebAuthenticationSession on any OS version that supports it.

    • Android: Custom Tabs (androidx.browser.customtabs) — the equivalent mechanism, backed by whichever Custom-Tabs-capable browser is installed (typically Chrome), sharing that browser’s cookie jar and address bar while visually docking into the calling app.

Both options satisfy §8.12; the choice between them is a UX trade-off, not a security one — pick the in-app browser tab unless there is a specific reason to want the more disruptive full app-switch of the external browser.

Wire-level: the authorization request from a native app

The request itself looks like any other authorization-code request — what differs is where it is opened (an in-app browser tab or external browser, never an embedded WebView) and the redirect_uri scheme, which must be one of the three options below:

GET /authorize?response_type=code
    &client_id=native-app
    &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
    &scope=orders.read%20offline_access
    &state=af0ifjsldkj
    &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
    &code_challenge_method=S256 HTTP/1.1
Host: as.example.com

The authorization server authenticates the user and obtains consent entirely on its own login page — the app supplies none of that UI — then redirects back to the claimed https://app.example.com/callback URI, which the operating system routes straight back into the app because of the verified Universal Link / App Link association, without ever opening in an ordinary browser tab visible to the user:

HTTP/1.1 302 Found
Location: https://app.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj

The app then completes the exchange itself, from its own process, presenting the code_verifier that matches the code_challenge sent above:

POST /token HTTP/1.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&client_id=native-app
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

Because native-app is a public client, no client_secret is presented at all — code_verifier is the only proof the token request has to offer, which is exactly why PKCE is not optional for this client shape.

The three redirect-URI options

Once the authorization server has authenticated the user and obtained consent, it needs to redirect back into the native app — there is no page for the browser to keep showing. RFC 8252 §7 covers three ways to make that redirect land back in the app, each with a different trade-off:

Option How it works Trade-offs and hijacking risk

Private-use URI scheme (e.g. com.example.app:/callback)

The app registers a custom URI scheme with the OS; the OS routes any link using that scheme to the registered app.

Simplest to set up, but schemes are not exclusive — any app can attempt to register the same scheme, and on a device where more than one app claims it, the OS’s tie-breaking behaviour is not guaranteed to favour the legitimate app. A malicious app registering the same scheme can intercept the authorization response, including the authorization code. Mandatory PKCE (below) is exactly what neutralises this: even if a rogue app captures the code, it cannot redeem it without the code_verifier that only the legitimate app holds.

Claimed https redirect (Universal Links on iOS, App Links on Android)

The redirect URI is an ordinary https:// URL on a domain the app’s developer controls and has cryptographically proven ownership of, via a signed association file the OS verifies (apple-app-site-association / Android’s Digital Asset Links).

The strongest option: the OS verifies domain ownership before ever routing the link to the app, so there is no scheme-squatting equivalent. Requires owning and maintaining the claimed domain and its association file, and a small number of OS/browser edge cases (the user disabling universal links, or opening the link in a context that doesn’t honour them) can fall through to opening the link in an ordinary browser tab instead of the app.

Loopback interface redirect (http://127.0.0.1:{port}/callback)

The app runs a temporary local HTTP listener on the loopback interface and uses that as the redirect URI; the system browser delivers the redirect straight to the app’s own local server.

Well suited to native desktop apps without a claimed HTTPS domain or scheme registration story. RFC 8252 requires using the loopback IP literal (not localhost, which can resolve unpredictably) and choosing the listener port at request time so it can be reflected exactly in redirect_uri. Since any process on the same device could in principle bind the same port first, this still depends on PKCE to make an intercepted code useless.

PKCE is mandatory for public native clients, full stop — this is true independently of the OAuth 2.1 draft’s broader move to require PKCE for every authorization-code client. A native app has no client secret to protect an intercepted authorization code with; the code_verifier / code_challenge pair from Authorization Code and PKCE is the only thing standing between a redirect-URI hijack (of any of the three kinds above) and a stolen token. Never ship a native OAuth integration without it.

App-to-app SSO and OIDC Native SSO

The redirect-URI mechanics above solve one app authenticating once. A user who has, say, five apps from the same organisation on their device still benefits from a single sign-on experience across them, and two complementary mechanisms address that:

  • App-to-app SSO via the shared browser session. Because both the external browser and the in-app browser tab share the system browser’s cookie jar, a user who authenticates once in one app’s ASWebAuthenticationSession / Custom Tab is, from the authorization server’s point of view, already logged in when a second app opens its own authorization request — the authorization server’s session cookie is presented automatically, and the user typically sees only a consent screen (or nothing at all, for a first-party app configured to skip consent) rather than a fresh login prompt.

  • OIDC Native SSO for Mobile Apps 1.0 goes further: it standardises a device secret mechanism so that a refresh token obtained by one app in an SSO-enabled group can be used, via a token-endpoint extension, to obtain tokens for a sibling app in the same group without any browser interaction at all — useful for suites of first-party apps that want SSO to survive even when the shared-cookie-jar mechanism above is unavailable (for instance, across app switches with no browser step at all).

AppAuth for iOS and Android

Implementing the redirect handling, PKCE generation, and browser-session management above correctly, by hand, on every platform is exactly the kind of security-sensitive plumbing best not reinvented per app. AppAuth (AppAuth-iOS / AppAuth-Android), maintained under the OpenID Foundation umbrella, is the reference client library that implements RFC 8252 and OIDC correctly out of the box — in-app browser tab selection, PKCE, and the redirect-URI handling for whichever of the three options above is configured — and is the recommended starting point rather than a hand-rolled integration.

Why the authorization server, not the app, should own login

Everything above has been about how to open the authorization request safely. This section is about why it matters that it happens in a real browser the authorization server controls, rather than inside the app — which is the payoff the whole native-app story is building toward.

The moment login happens in a system browser or in-app browser tab pointed at the authorization server’s own login page, authentication becomes entirely the authorization server’s responsibility, invisible to the app. That single fact unlocks capabilities that are structurally unavailable when an app instead collects a password itself and posts it to a token endpoint — which is exactly the ROPC pattern documented, and rejected, on Legacy Grants: Implicit and Password:

  • CAPTCHA and bot/brute-force defences. A browser-rendered login page can run a CAPTCHA challenge, rate-limit by IP or device fingerprint, and apply risk-based throttling — none of which has anywhere to run inside a bare token-endpoint request.

  • Step-up and 2FA. The authorization server’s login page can prompt for a second factor exactly when its own risk engine decides one is warranted — see Authentication Methods: 2FA and Passwordless for the full factor catalogue and the RFC 9470 step-up challenge mechanism. 2FA here is always an additional layer on top of whichever primary factor the user is authenticating with — it is never itself a login method, and adding it never makes a flow "passwordless" on its own.

  • Passkeys. WebAuthn/FIDO2 ceremonies are, by their nature, browser (or platform-authenticator) APIs; they are simply not reachable from a form field an app posts to a token endpoint.

  • Device-risk checks. Browser-side device and session signals (cookies, TLS fingerprinting, prior-session history) that a risk engine can evaluate before allowing a login are only visible when the login happens in that browser context in the first place.

  • Consent screens. The authorization server can show, and the user can decline, exactly what scopes are being requested — something a native login form has no protocol slot for at all.

  • Federation and password-manager autofill. "Sign in with Google/Apple/Microsoft" buttons, and the browser’s own password-manager autofill, both depend on the login happening in a real browser context that those integrations can hook into.

  • Every one of the above can change without an app release. Because all of this logic lives on the authorization server’s login page, tightening a CAPTCHA threshold, rolling out step-up for a newly risky scope, or adding a new federated identity provider is a server-side deployment — no app-store review, no staged rollout, no fleet of installed app versions to worry about supporting.

None of this is possible when the app itself renders the login form and posts a password straight to a token endpoint, because at that point the authorization server never gets a chance to run any of its own login logic at all — it just receives a credential and has to trust it blindly. This is precisely the gap Legacy Grants: Implicit and Password documents as disqualifying for ROPC, and it is why "open a real browser" is not a compliance checkbox on this page but the actual mechanism that makes every one of the capabilities above possible.

Sequence: authorization code + PKCE from a native app, via an in-app browser tab

sequenceDiagram participant App as Native app participant Tab as In-app browser tab\n(ASWebAuthenticationSession / Custom Tabs) participant AS as Authorization server App->>App: Generate code_verifier, derive code_challenge (S256) App->>Tab: Open authorization URL\nresponse_type=code, code_challenge, code_challenge_method=S256,\nredirect_uri (claimed https or private scheme) Tab->>AS: GET /authorize (shares system browser's cookie jar) AS->>AS: Authenticate user (CAPTCHA, 2FA, passkeys as the AS decides)\nshow consent screen AS-->>Tab: 302 redirect to redirect_uri with code and state Tab-->>App: Deliver redirect (claimed-https link, custom scheme,\nor loopback listener) App->>AS: POST /token\ngrant_type=authorization_code, code, code_verifier, redirect_uri AS->>AS: Verify code_verifier against stored code_challenge AS-->>App: access_token, refresh_token, id_token

Common pitfalls to audit for

A codebase that predates RFC 8252, or one that was ported from a platform SDK’s older sample code, often carries one or more of these — each is worth an explicit grep before calling a native OAuth integration compliant:

  • An embedded WKWebView/UIWebView or Android WebView anywhere near the string /authorize — the single clearest §8.12 violation, regardless of how well-intentioned the surrounding code is.

  • A redirect_uri using a private-use scheme with no PKCE — the scheme-squatting risk described above with no mitigation in place at all.

  • A claimed-https redirect URI whose association file (apple-app-site-association / assetlinks.json) is missing, misconfigured, or not served over HTTPS with the correct content type — silently falling back to opening the redirect in an ordinary browser tab instead of routing into the app.

  • A loopback redirect using localhost instead of the 127.0.0.1 literal RFC 8252 requires — localhost resolution is not guaranteed consistent across a device’s network configuration.

  • Refresh tokens or access tokens written to platform storage that is not the OS-provided secure storage (Keychain on iOS, EncryptedSharedPreferences/Keystore-backed storage on Android) — a native-app storage concern parallel to, but distinct from, the browser storage concerns on Browser-Based Apps (SPAs).

Figure: what each user agent lets each actor see

Three columns comparing an embedded WebView