That "Continue with Google" button hides one of development's most misunderstood protocols. Confusion starts at the base: OAuth 2.0 doesn't authenticate anyone — it delegates authorization. Authentication arrives with OpenID Connect, a layer on top. Understanding this distinction (plus the current correct flow) separates secure integrations from security holes dressed as social login.
The four actors
OAuth defines roles with precision worth respecting:
- Resource Owner: the user owning their data.
- Client: your application, wanting access on their behalf.
- Authorization Server: Google, GitHub... whoever verifies identity and issues tokens.
- Resource Server: the API where data lives (profile, repos, email).
The original use case: "let this app read my Google Photos WITHOUT giving it my password". That "without my password" is the protocol's reason to exist.
The modern flow: Authorization Code + PKCE
Since 2019, PKCE (Proof Key for Code Exchange) is mandatory for public apps and recommended for all. The complete flow:
1. Your app generates a random verifier (code_verifier) and
stores its hash (code_challenge).
2. Redirect to the authorization server:
https://accounts.google.com/o/oauth2/v2/auth
?client_id=YOUR_ID
&redirect_uri=https://yourapp.com/callback
&response_type=code
&scope=openid email profile
&state=random_anti_csrf_value
&code_challenge=HASH&code_challenge_method=S256
3. User signs into Google and approves scopes.
4. Google redirects to your callback with ?code=XXX&state=...
5. You verify state matches; exchange code for tokens
IN YOUR BACKEND (with client_secret + code_verifier):
POST https://oauth2.googleapis.com/token
6. You receive: access_token (+ refresh_token) and id_token if you asked openid.
Why each piece exists:
state: random value bound to the session; must match on return. Without it, an attacker can inject THEIR authorization code into YOUR session (login CSRF).- PKCE:
code_verifierlives only in your app; the final exchange demands it. If someone intercepts thecode(it travels via URL), it's useless without the verifier. It's the modern replacement for secrets impossible to store in mobile/SPA apps. - Backend exchange:
client_secretnever touches the browser. Pure SPAs run the same flow without secret — hence PKCE stopped being optional.
OAuth ≠ login: enter OpenID Connect
Here lies the foundational misunderstanding. An OAuth access_token answers "what can this app do?" — not "who are you?". Using it as session credential produces classic bugs: tokens accepted across services without audience checks, sessions that don't expire properly, impossible real logout.
OpenID Connect adds identity pieces over OAuth:
- Special scope
openid. - The ID Token: a signed JWT whose payload declares who you are:
{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334",
"aud": "YOUR_CLIENT_ID",
"exp": 1756018800,
"email": "user@gmail.com",
"email_verified": true
}
Your backend validates that JWT: signature against public keys published at /.well-known/jwks.json, correct issuer (iss), audience (aud) = your client_id, and expiration. Only after validating EVERYTHING does it create the local session. Paste any id_token into our JWT decoder — you'll see exactly these claims.
Mnemonic rule: access_token to call APIs, ID token to know who you are, refresh_token to renew. Never cross them.
Scopes: request only what's needed
Scopes delimit granted power. Practices avoiding audit scares:
- Least privilege: do you need
gmail.readonlyto show "continue with Google"? No —openid email profilesuffices. - Incremental scopes: request extra permissions when features use them, not upfront.
- Show users WHAT you'll access; consent screens exist for this.
Frequent implementation mistakes
Loose redirect_uri: accepting substrings or wildcards lets codes redirect to attacker domains. Exact matching always.
Missing or predictable state: login CSRF is real and automatable.
Validating id_token by merely decoding it: Base64 isn't verification. Without checking signature, iss, aud and exp, anyone forges identity. Mature OIDC libraries run the whole pipeline — use them, don't hand-roll validation.
Tokens in localStorage: same risks as in the JWT/sessions debate. For traditional web: code → backend → HttpOnly cookie session. The browser never touches long-lived tokens.
Confusing roles in homegrown architectures: mounting your own internal authorization server demands equal rigor — many internal systems die from "it's just internal" shortcuts.
When you DON'T need OAuth
If your app is a monolith with its own users, classic login with session cookie is simpler and sufficient. OAuth shines when: delegating identity (social login), exposing APIs to third parties, or connecting services together. Adding it "because it's modern" multiplies attack surface with no benefit.
FAQ
Can I use Google's access_token against MY API? Technically yes (Google signs JWTs too), but you lose revocation control and couple auth to third parties. Healthy pattern: OIDC for identity → your own session for everything else.
What happens when access_token expires? With refresh_token, your backend renews silently. Rotating refreshes (new token per use, reuse detection) are today's standard.
Implicit flow? Officially obsolete since the OAuth 2.1 draft: exposed tokens in URL fragment without PKCE. Tutorials with response_type=token predate 2018 — ignore them.
Inspect your integrations' tokens with our JWT decoder, free without leaving your browser.