A Login Flow End to End
"Sign in with Google" from a mobile app, request by request.
After this lesson you can explain
- What happens between the tap on "Sign in with Google" and the app holding tokens, request by request.
- Why PKCE,
state, andnonceeach exist, and the attack each one blocks. - Where the provider's flow ends and your own API's token auth begins, and why your backend must verify the ID token itself.
1. What flow this actually is
The button says "Sign in with Google", but the machinery underneath is the OAuth 2.0 authorization code flow with PKCE, carrying OpenID Connect (OIDC) on top. The 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.
Every "Sign in with X" button (Google, GitHub, Apple, Spotify) runs essentially this same dance. Learn it once and you have read them all. If you have worked through the free guide OAuth 2.0 in Python: The Authorization Code Flow Explained, this is the same flow seen from the app's side of the wire.
2. The full sequence at a glance
App System browser Google Your backend
| | | |
|--1 make PKCE pair, | | |
| state, nonce | | |
|--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/nonce |
|<----- your API's access + refresh tokens -------------------------|
|--7 store in Keychain/Keystore | |
|--8 Authorization: Bearer ... on every API call ------------------>
3. Before any user shows up: registration
The developer registers the app in the Google Cloud console and receives a client ID, and registers a redirect URI. On mobile that is a custom scheme like com.myapp:/callback, or better, an App Link / Universal Link (an https URL cryptographically bound to the app), which a malicious app cannot claim.
No client secret. Web servers get a client secret; mobile apps deliberately do not. A secret compiled into an app installed on a million phones is not a secret; anyone can decompile the binary and read it. PKCE (next section) exists precisely to fill the gap.
4. The flow, step by step
Step 1: The tap. Generate throwaway values
The moment the user taps the button, the app generates:
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).nonce: a random value that must come back inside the ID token (replay protection).
Step 2: Open the system browser at Google's authorization endpoint
The app opens a Chrome Custom Tab (Android) or ASWebAuthenticationSession (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.myapp:/callback
&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 profile ask for those claims inside it.
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.myapp:/callback?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 short-lived (about 10 minutes), 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.myapp:/callback
&grant_type=authorization_code
&code_verifier=dBjftJeZ4CVP... <- the secret kept 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...", // optional, for offline access
"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: ishttps://accounts.google.com.aud: is your client ID (a valid Google token minted for someone else's app must be rejected).exp: not expired.nonce: matches step 1 (the app passes it along for the backend to check).
The classic vulnerability: decoding the JWT payload and trusting it without checking the signature. A JWT is just base64: anyone can mint one claiming to be anyone. Verification against the issuer's public keys is the entire security boundary. Use a maintained library (google-auth in Python does all five checks in one call), never hand-rolled decoding.
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 Keychain (iOS) / Keystore (Android), never plain 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: each refresh also issues a new refresh token and invalidates the old one. If a stolen refresh token is ever used, the collision reveals the theft and the backend can revoke the whole 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.
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 app that started the flow (PKCE) |
state | The app | One flow | Binds the redirect to the request; blocks injected responses (CSRF) |
nonce | The app | One flow | Binds the ID token to the request; blocks token replay |
| Authorization code | ~10 min, single use | Voucher exchanged for tokens; worthless if intercepted (PKCE) | |
| Google access token | ~1 hour | Calling Google's own APIs on the user's behalf | |
| ID token | ~1 hour | Signed proof of identity, consumed once by your backend at login | |
| 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 (stolen code is useless without the verifier); App Links bind the URI to the genuine app |
| Attacker tricks the app into completing their login (session fixation) | state check on the redirect |
| Replay of a captured ID token | nonce check; 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 equal your client ID |
| App harvesting the user's Google password | System browser, never a WebView; Google blocks WebView logins |
| Stolen long-lived refresh token | Secure storage + rotation: reuse of a rotated-out token exposes the theft |
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.
- Native sign-in SDKs: Google's Credential Manager (Android) and Sign in with Apple present a native sheet instead of a browser tab. Less visible ceremony, same artefacts underneath: you still end up with an ID token that your backend must verify identically from step 6 onward.
- "Sign in with GitHub/Spotify/etc.": the same authorization-code dance; some providers are OAuth-only (no OIDC ID token), in which case the backend calls the provider's
/userendpoint with the access token to learn who logged in, then continues from step 7 unchanged. - 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: steps 1–5 are the provider's standard machinery, identical across every "Sign in with X" button. Steps 6–8 are ordinary API token auth. A "social login" is two auth systems stitched together at step 6, and once you see the seam, every login flow you meet decomposes the same way.
Check your understanding
- Why does a mobile app get no client secret, and what fills the gap?
- 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 4 August 2026.