A Login Flow End to End
"Sign in with Google" from a mobile app, request by request.
1. What flow this actually is
The button says "Sign in with Google", but mobile apps reach the same result through two common client-side routes. Google's current Android integration uses Credential Manager to return an ID token directly. On iOS, and with providers that support browser-based native OAuth, the visible machinery is the OAuth 2.0 authorization code flow with PKCE, carrying OpenID Connect (OIDC) on top. Both routes meet your backend at the same verification seam. The OAuth/OIDC distinction is worth keeping straight:
- OAuth 2.0 answers "what is this app allowed to do?" (authorization). It delivers an access token.
- OpenID Connect is a thin layer on OAuth that answers "who is this user?" (authentication). It delivers an ID token, a signed JWT stating the user's identity.
Many "Sign in with X" buttons run some version of this authorization-code dance, but the artefacts vary: OIDC providers return an ID token, while OAuth-only providers require another request to learn who signed in. Learn the seam between provider identity and your own API session and the variations become much easier to read. If you have worked through the free guide OAuth 2.0 in Python: The Authorization Code Flow Explained, this is the same family of flow seen from the app's side of the wire.
2. The browser-based sequence at a glance
App System browser Google Your backend
| | | |
|--1 make PKCE pair, | | |
| state | | |
|--request one-time login nonce ----------------------------------->|
|<----------------------- nonce (stored as pending by backend) -----|
|--2 open auth URL--->| | |
| |--GET /auth ------->| |
| |<--login page ------| |
| |--credentials ----->| (app never sees them) |
| |<--redirect w/ code-| |
|<-3 OS routes custom | | |
| scheme back to app| | |
|--4 check state | | |
|--5 POST /token + code_verifier --------->| |
|<----- access token + ID token (JWT) -----| |
|--6 POST /auth/google { id_token } ------------------------------->
| | |<--fetch JWKS keys -----|
| | | verify sig/iss/aud/ |
| | | exp; match + consume |
| | | stored nonce |
|<-7--- your API's access + refresh tokens -------------------------|
|--8 store in platform secure storage | |
|----- Authorization: Bearer ... on every API call ----------------->
Google on Android takes a shorter client-side route. Credential Manager presents the account sheet and returns a Google ID token; your app supplies the backend-issued nonce through setNonce(), then rejoins this diagram at step 6. The app does not directly open Google's authorization endpoint, receive a redirect, or exchange an authorization code. If the app also needs access to Google data such as Drive files, Android treats that as a separate authorization action.
3. Before any user shows up: registration
The developer registers the app in the Google Cloud console and receives a client ID. The browser-based route also needs a registered redirect URI. Google still supports a reversed-client-ID custom scheme on iOS; other providers may support custom schemes or claimed https links that the operating system verifies belong to the genuine app.
Google-specific Android rule: Google no longer accepts custom-scheme redirects for Android because another app can impersonate the scheme owner. Use Credential Manager for Google sign-in on Android. The custom-scheme examples below therefore show the Google-on-iOS/browser branch, not an Android implementation.
No client secret. A confidential web server can authenticate with a client secret; a mobile app deliberately cannot. A shared value compiled into an app installed on a million phones is extractable, so the authorization server treats the app as a public client. PKCE does not make the app confidential or prove it is genuine; it protects the authorization code so an interceptor cannot redeem it without the one-time verifier.
4. The flow, step by step
Step 1: The tap. Create the transaction
For the browser-based branch, the moment the user taps the button, the app generates three throwaway values:
code_verifier: a long random string, kept in memory, never sent yet.code_challenge: the SHA-256 hash of the verifier. This one gets sent.state: a random value to bind the request to the response (CSRF protection).
Both branches ask your backend for a short-lived, one-time nonce. The backend records that pending login challenge and returns the random value to the app. The browser branch includes it in the authorization request; Credential Manager receives it through setNonce(). Google must return the same value inside the ID token. When the token reaches your backend in step 6, the backend can match the claim to state it already trusts and consume the challenge exactly once.
Step 2: Open the system browser at Google's authorization endpoint
In this branch, the app opens an external system-browser surface such as ASWebAuthenticationSession on iOS, crucially not an embedded WebView. The request is a plain GET:
https://accounts.google.com/o/oauth2/v2/auth
?client_id=1234-abc.apps.googleusercontent.com
&redirect_uri=com.googleusercontent.apps.1234-abc%3A%2Foauth2redirect
&response_type=code
&scope=openid%20email%20profile
&state=xyz123
&code_challenge=E9Mel...
&code_challenge_method=S256
&nonce=n0nc3
scope=openid email profile is what makes this an OIDC request: openid asks for an ID token; email and profile request the user's email address and basic profile, which Google delivers as claims inside that token.
Why never a WebView: a WebView is rendered inside the app, so the app could read every keystroke, including the user's Google password. The system browser is a separate OS surface the app cannot see into. It also shares cookies with the user's real browser, so an already-signed-in user skips the password entirely. Google actively detects and blocks WebView logins for exactly this reason.
Step 3: The user authenticates on Google's turf
Password, passkey, two-factor prompt, or nothing at all if a session cookie already exists. If it's the app's first time, Google shows a consent screen listing the requested scopes. The app never sees any of this exchange; that is the entire point of the design.
Step 4: The redirect hands back an authorization code
com.googleusercontent.apps.1234-abc:/oauth2redirect?code=4/0AX4Xf...&state=xyz123
The OS sees the custom scheme (or App Link) and routes control back to the app. The app immediately checks the returned state equals the one from step 1; a mismatch means someone is injecting a foreign response, so the app aborts.
Note what the code is: a provider-defined, short-lived, single-use voucher. It is not a token and grants nothing by itself.
Step 5: Exchange the code for tokens (the PKCE moment)
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded
code=4/0AX4Xf...
&client_id=1234-abc.apps.googleusercontent.com
&redirect_uri=com.googleusercontent.apps.1234-abc:/oauth2redirect
&grant_type=authorization_code
&code_verifier=dBjftJeZ4CVP... <- the one-time verifier from step 1
Google hashes the code_verifier and compares it with the code_challenge from step 2. Match → tokens. This is PKCE's trick: even if a malicious app on the same phone intercepted the redirect and stole the code, it never had the verifier, so the code is useless to it.
The response:
{
"access_token": "ya29.a0Af...", // for calling Google's own APIs
"id_token": "eyJhbGciOi...", // signed JWT: who the user is
"refresh_token": "1//0gFt...", // returned for installed apps; unused for login alone
"expires_in": 3599
}
Step 6: Your backend verifies the ID token
The step most tutorials skip. Google has authenticated the user, but your API has not. The app sends the ID token to your backend:
POST /auth/google
{ "id_token": "eyJhbGciOi..." }
The backend must verify it properly, not just decode it:
- Signature: checked against Google's published public keys (the JWKS endpoint, cached and rotated).
iss: is one of Google's documented issuer values:https://accounts.google.comoraccounts.google.com.aud: is one of the client IDs your backend accepts (a valid Google token minted for someone else's app must be rejected).exp: not expired.nonce: matches an unexpired login challenge the backend issued in step 1. The backend consumes it atomically so the same ID token cannot establish a second session.
A nonce merely supplied beside the token would prove nothing: an attacker replaying a captured token could send its nonce too. In this design the check has teeth because the backend recognises the value from its own pending-login store.
The classic vulnerability: decoding the JWT payload and trusting it without verifying it. A decoded payload is only base64url-encoded data; anyone can fabricate one claiming to be anyone. Use a maintained library rather than hand-rolled verification. In Python, google-auth verifies the signature and standard claims such as issuer, audience, and expiry. Your application must still compare the nonce with the pending challenge stored by your backend and consume it once.
Step 7: Your backend issues its own tokens
The backend looks up or creates the user keyed on the token's sub claim (Google's permanent, unique user ID). Key on sub, not email: emails can change and, on some providers, be unverified. Then it mints its own access token + refresh token for your API and returns them. From this point Google is out of the picture; the session is purely between your app and your backend.
Step 8: Steady state
- Tokens stored in the Keychain on iOS; on Android, encrypted storage whose encryption keys are protected by the Keystore. Never plain files or preferences.
- Every API call:
Authorization: Bearer <access token>. - Access token expires (typically 15–60 min) → the app silently POSTs the refresh token to
/auth/refreshfor a new pair. - Rotation with reuse detection: each refresh issues a new refresh token and invalidates the old one. The backend retains token-family state; if an invalidated ancestor appears again, that reuse reveals likely theft and the backend revokes the family.
- Logout: delete local tokens and call the backend to revoke the refresh token server-side. Deleting locally alone leaves a live credential in the wild.
Lesson 10 returns to secure storage and the limits of hardening a public client.
5. What each artefact is for
| Artefact | Issued by | Lifetime | Purpose |
|---|---|---|---|
code_verifier / code_challenge | The app | One flow | Proves the token exchange comes from the same flow instance that started it (PKCE) |
state | The app | One flow | Binds the redirect to the request; blocks injected responses (CSRF) |
nonce | Your backend | One flow | Binds the ID token to a pending login transaction; consumed once to block replay |
| Authorization code | Short-lived, single use | Voucher exchanged for tokens; an interceptor cannot redeem it without the PKCE verifier | |
| Google access token | ~1 hour | Calling Google's own APIs on the user's behalf | |
| ID token | ~1 hour | Signed proof of identity; accepted for login only with an unused backend-issued nonce | |
| Your access token | Your backend | 15–60 min | Authenticates every call to your API |
| Your refresh token | Your backend | Weeks/months, rotated | Silently renews the access token; revocable server-side |
6. Why each defence exists
| Attack | Defence |
|---|---|
| Malicious app registers the same custom scheme and catches the redirect | PKCE makes the stolen code useless without the verifier; claimed https links bind the URI to the genuine app. Google goes further on Android and does not support custom-scheme redirects |
| Attacker tricks the app into completing their login (a login CSRF: the victim ends up signed into the attacker's account) | state check on the redirect |
| Replay of a captured ID token | Backend-issued, atomically consumed nonce; short exp |
| Forged JWT claiming to be any user | Signature verification against Google's JWKS |
| Valid Google token from a different app replayed against your API | aud must match one of your backend's accepted client IDs |
| App harvesting the user's Google password | System browser, never a WebView; Google blocks WebView logins |
| Stolen long-lived refresh token | Secure storage + rotation with token-family reuse detection |
7. Variations worth knowing
- Web instead of mobile: same flow, but the redirect URI is an https URL on your domain, the exchange in step 5 happens server-side with a client secret, and the session usually ends up in an httpOnly cookie rather than stored tokens. PKCE is now recommended there too. This server-side variant is exactly what the OAuth guide builds in Python.
- Google on Android: Credential Manager presents a native account sheet and returns a Google ID token for your backend to verify. It handles authentication; requesting access to Google data is a separate operation through Google's authorization API.
- Other native sign-in SDKs: Sign in with Apple and provider SDKs may present native UI instead of a browser tab. The returned artefacts vary, but your backend still verifies provider-issued evidence before creating its own session.
- "Sign in with GitHub/Spotify/etc.": many use an authorization-code dance, but some providers are OAuth-only and return no OIDC ID token. In that case the backend calls the provider's
/userendpoint with the provider access token to learn who logged in, then continues from step 7. - Magic links (the model endpoint51.com itself uses): a different first factor entirely (possession of an inbox instead of a Google account), but it converges at the same place. The backend confirms identity, then issues its own session credential. Steps 7–8 are universal; only steps 1–6 vary by provider.
The takeaway shape: apart from the one-line backend nonce request, steps 1–5 are the provider-facing machinery; Android Credential Manager compresses those client-side steps into its own API. Steps 6–8 are your API's session machinery. A "social login" is two auth systems stitched together at that seam, and once you see it, unfamiliar login flows become much easier to decompose.
Check your understanding
- Why does a mobile app get no client secret, and what specific interception attack does PKCE prevent?
- Why does this design have the backend issue and remember the OIDC nonce instead of accepting a nonce supplied alongside the ID token?
- Which check stops a valid Google token minted for a different app being accepted by your API?
- At which step does Google leave the picture, and what does your backend issue in its place?
Sources and further reading
Reviewed 6 August 2026.