Skip to content

Zoom-in: OAuth 2.0

Karify98·
Cover Image for Zoom-in: OAuth 2.0

"Sign in with Google" — three words, one click. Behind that is a mechanism where the password never leaves Google, the app never sees the user's credentials, and the user can revoke access at any time without changing their password.

Zoom in.


Layer 1 — The delegation problem

The old approach: to let App A read a user's Google email, the user must hand over their username and password to App A.

graph LR
    U["👤 User"] -->|"username + password"| A["📱 App A"]
    A -->|"login as user"| G["🔑 Google"]
    style U fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
    style A fill:#1a3a2a,stroke:#22c55e,color:#86efac
    style G fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d

The problem: App A has full access to the Google account — no scope limits, no way to revoke independently, and if App A leaks data then the user's Google password is compromised too.

OAuth 2.0 solves this with a different model: instead of handing over the house key, issue a token with a limited lifespan and scope.

Problem remaining: the token must reach the app securely — without going through the browser URL (which can leak into logs).

Layer 2 — Authorization Code flow

OAuth 2.0 splits the process into two steps: get a code → exchange code for token.

sequenceDiagram
    participant U as 👤 User
    participant A as 📱 App (Client)
    participant G as 🔑 Google Auth Server
    participant R as 📦 Resource Server (Gmail API)

    U->>A: "Sign in with Google"
    A->>G: Redirect → /authorize?client_id=...&scope=email&redirect_uri=...&state=xyz
    G-->>U: Show consent screen
    U->>G: Grant permission
    G-->>A: Redirect to redirect_uri?code=AUTH_CODE&state=xyz
    A->>G: POST /token (code=AUTH_CODE + client_secret)
    G-->>A: access_token + refresh_token
    A->>R: GET /gmail/messages (Authorization: Bearer access_token)
    R-->>A: Email data

The authorization code lives for only a few seconds and is single-use — it travels through the browser URL, but it's not the real token. The actual token (access_token) is exchanged server-to-server using client_secret, never through the browser.

The state parameter is a random string generated by the app — used to prevent CSRF: if the response returns a different state than what was sent, the flow is aborted.

Problem remaining: the access_token is short-lived. Users can't be asked to log in again every hour. A refresh mechanism is needed that works without bothering the user.

Layer 3 — Access token vs Refresh token

Two tokens, two distinct purposes.

graph LR
    A["📱 App"] -->|"access_token (15 min)"| R["📦 Resource Server"]
    A -->|"refresh_token (30 days)"| G["🔑 Auth Server"]
    G -->|"new access_token"| A
    style A fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
    style R fill:#1a3a2a,stroke:#22c55e,color:#86efac
    style G fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
Access token Refresh token
Sent to Resource server (every request) Auth server (when access token expires)
Lifespan 15 min – 1 hour Days to months
If leaked Dangerous for a short window More dangerous, but revocable

Splitting into two tokens reduces risk: the short-lived access token limits the attack window if leaked. The refresh token only goes to the auth server — far less exposure surface.

Problem remaining: the flow above requires a client_secret — which is only safe in a server-side app. Mobile apps and SPAs have nowhere to store a secret safely.

Layer 4 — PKCE: protecting public clients

Mobile apps and browser SPAs are public clients — no server, nowhere to keep a client_secret. PKCE (Proof Key for Code Exchange) replaces the client secret with a temporary key pair generated per request.

sequenceDiagram
    participant A as 📱 Mobile App
    participant G as 🔑 Auth Server

    Note over A: Generate code_verifier (random, 43-128 chars)
    Note over A: code_challenge = SHA256(code_verifier)

    A->>G: /authorize?...&code_challenge=...&code_challenge_method=S256
    G-->>A: code=AUTH_CODE

    A->>G: /token (code=AUTH_CODE + code_verifier)
    Note over G: SHA256(code_verifier) == code_challenge? ✓
    G-->>A: access_token + refresh_token

The code_verifier only lives in the app's memory and never leaves the app until the token exchange step. Even if AUTH_CODE is intercepted, an attacker without the code_verifier cannot exchange it for a token.


Full picture

sequenceDiagram
    participant U as 👤 User
    participant A as 📱 App
    participant G as 🔑 Auth Server
    participant R as 📦 Resource Server

    Note over A: Generate state + PKCE (if public client)
    A->>G: /authorize (scope, redirect_uri, state, code_challenge)
    G-->>U: Consent screen
    U->>G: Grant access
    G-->>A: code + state

    Note over A: Validate state
    A->>G: /token (code + client_secret or code_verifier)
    G-->>A: access_token (15min) + refresh_token (30 days)

    loop Per request
        A->>R: API call (Bearer access_token)
        R-->>A: Data
    end

    Note over A: access_token expires
    A->>G: /token (refresh_token)
    G-->>A: new access_token

Takeaway

OAuth 2.0 separates three concerns: who the user is, who is granted permission, and what that permission covers. The password never leaves the auth server. Tokens carry scoped, time-limited access. The refresh token maintains the session without prompting the user again.

Two implementation details that often get skipped: validate state to prevent CSRF, and use PKCE for all public clients — even when client_secret is supported, because PKCE adds a protection layer at no real cost.


This post was assisted by Amy 🌸 - AI Assistant. Content has been reviewed by the author.

Related Posts