OAuth 2.0 and OpenID Connect in Depth: Authorization Flows and Social Login in Practice

17 min read

Part 1 of this series drew a hard line between authentication (who you are) and authorization (what you're allowed to do). Part 2 picked up right after authentication succeeds and asked: once we know who the user is, where does that fact live for the rest of the session — a cookie-backed session, or a signed JWT? We closed with a question we deliberately left open: which clients even need a token, and how do they get one in the first place?

That's this post. Before a session or a JWT can exist, something has to happen: the user has to prove who they are, and — in an increasing number of systems — that proof has to come from somewhere the user already trusts, like Google or GitHub, rather than a password field you built yourself. That's the job of OAuth 2.0 and OpenID Connect. They don't replace what Part 2 covered; they're the mechanism that produces the credential Part 2 assumed you already had.

Quick answer

OAuth 2.0 is a delegated authorization framework. It lets an application get limited access to a resource on a user's behalf, without ever seeing the user's password. It answers "can this app read my calendar?" — not "who is this person?"

OpenID Connect (OIDC) is an identity layer built directly on top of OAuth 2.0. It adds a standardized way to answer "who is this person?" by introducing the ID token, a signed JSON Web Token containing claims about the user.

Every "Sign in with Google" button you've ever clicked is OIDC using OAuth's plumbing underneath it.

User          Client App        Authorization Server (Google)      Resource Server
 |                 |                        |                            |
 | 1. Click login  |                        |                            |
 |---------------->|                        |                            |
 |                 | 2. Redirect to /authorize                           |
 |<----------------|----------------------->|                            |
 | 3. Login + consent at Google             |                            |
 |------------------------------------------>|                           |
 |                 | 4. Redirect back with authorization code            |
 |<-------------------------------------------                          |
 |                 | 5. Exchange code for tokens (server-to-server)      |
 |                 |----------------------->|                            |
 |                 | 6. access_token + id_token (+ refresh_token)        |
 |                 |<-----------------------|                            |
 |                 | 7. Call API with access_token                      |
 |                 |------------------------------------------------->  |
 |                 | 8. Protected data                                  |
 |                 |<-------------------------------------------------  |

That diagram is the shape of almost every real-world social login flow. The rest of this post is about the details inside each arrow — because that's where production bugs live.

The four roles

OAuth defines four actors, and confusing them is the single most common source of muddled explanations online.

  • Resource owner — the user. The person who owns the calendar, the repo, the profile data.
  • Client — your application. The thing that wants access. Note: "client" doesn't mean "frontend" — your backend can be the OAuth client too.
  • Authorization server — issues tokens after authenticating the resource owner and getting their consent. Google, GitHub, Auth0, your own identity provider.
  • Resource server — hosts the protected data and accepts the access token. Sometimes this is the same system as the authorization server (GitHub is both), sometimes it's a separate API.

Keep these four straight and every OAuth diagram you've ever found confusing gets much easier to read.

OAuth vs OIDC: framework vs identity layer

This is worth stating plainly because it's the most frequently blurred distinction in this space:

OAuth 2.0OpenID Connect
SolvesDelegated authorization ("can this app act on my behalf?")Authentication ("who is this user?")
Core artifactAccess token (opaque or JWT, meaning defined by the resource server)ID token (always a JWT, standardized claims)
Token audienceThe resource server (the API)The client application itself
Should you parse it?Never assume you can — treat as opaque unless the AS documents otherwiseYes — it's designed to be validated and read by the client

The access token is for the API. The ID token is for your app. Using an access token to figure out who logged in — a mistake I still see in code reviews — works by accident on some providers and breaks on others, because OAuth never specified what an access token has to contain. OIDC exists specifically to close that gap.

Authorization Code flow (with PKCE)

This is the flow you should default to for essentially everything in 2026 — server-rendered apps, SPAs, and mobile apps alike, the last two with PKCE added.

Step 1 — Redirect to the authorization endpoint.

GET https://accounts.google.com/o/oauth2/v2/auth?
  response_type=code
  &client_id=abc123.apps.googleusercontent.com
  &redirect_uri=https://myapp.com/auth/callback
  &scope=openid%20email%20profile
  &state=9f8a3c2e
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  • state is an opaque, unguessable value your app generates and stores (in a cookie or server-side session) before redirecting. You check it matches on the way back. This is your CSRF defense for the OAuth flow — skip it and an attacker can trick a victim into linking the attacker's third-party account to the victim's session.
  • code_challenge / code_challenge_method are PKCE (Proof Key for Code Exchange, pronounced "pixy"). Your app generates a random code_verifier, hashes it with SHA-256 to get code_challenge, and sends only the hash here. You'll need the original verifier again in step 3.

Step 2 — User authenticates and consents at the authorization server. This happens entirely on Google's (or GitHub's, or Auth0's) domain. Your app never sees the user's Google password. This is the entire point.

Step 3 — Redirect back with a code, then exchange it server-to-server.

The browser is sent back to your redirect_uri with ?code=...&state=.... You verify state, then your backend — not the browser — makes a direct server-to-server call to exchange the code for tokens:

const res = await fetch('https://oauth2.googleapis.com/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: 'https://myapp.com/auth/callback',
    client_id: process.env.GOOGLE_CLIENT_ID!,
    client_secret: process.env.GOOGLE_CLIENT_SECRET!, // confidential clients only
    code_verifier: storedCodeVerifier, // PKCE: proves you initiated this flow
  }),
});

const { access_token, id_token, refresh_token, expires_in } = await res.json();

This exchange happens outside the browser specifically so the client_secret never touches the user's device. That's why PKCE matters for SPAs and mobile apps: they're public clients — they can't hold a secret at all (anything shipped to the browser or decompiled from an app binary is public). PKCE replaces the secret with a per-flow proof: even if an attacker intercepts the authorization code (say, via a malicious app registering the same custom URL scheme), they can't exchange it without the code_verifier, which never left your app's memory.

The authorization code itself is short-lived (typically 30–60 seconds) and single-use by design — a second exchange attempt should be rejected by the authorization server.

The other grants: what they're for, and what to avoid

  • Implicit flow (response_type=token) — returns the access token directly in the URL fragment, no code exchange. Deprecated. Tokens end up in browser history, referrer headers, and server logs. OAuth 2.1 removes it entirely. If you see this in a tutorial, the tutorial predates 2019-ish best practice.
  • Client Credentials — no user involved at all. Service-to-service auth: your backend job authenticating to another API as itself. grant_type=client_credentials with a client ID/secret, straight to the token endpoint.
  • Device Authorization Grant — for input-constrained devices (smart TVs, CLI tools). The device displays a code and a URL; the user completes login on their phone or laptop while the device polls the token endpoint. This is how gh auth login and similar CLI flows work.
  • Refresh Token grant — exchanges a long-lived refresh token for a new access token without re-prompting the user. grant_type=refresh_token. Refresh tokens should be stored server-side or in a secure, httpOnly cookie — never in localStorage, for the same reasons covered in Part 2.

If you're starting a new integration today, Authorization Code + PKCE covers the SPA/mobile/server-app case, and Client Credentials covers service-to-service. You rarely need anything else.

ID token vs access token, and what scopes actually do

The ID token is a JWT with standardized claims:

{
  "iss": "https://accounts.google.com",
  "sub": "110169484474386276334",
  "aud": "abc123.apps.googleusercontent.com",
  "exp": 1755289200,
  "iat": 1755285600,
  "email": "user@example.com",
  "email_verified": true,
  "name": "Suriya Prakash"
}

It's signed, not encrypted — anyone can decode and read it, but only the authorization server's private key can produce a valid signature. Never treat "it's a JWT" as "it's confidential."

scope is a space-delimited string that tells the authorization server what the client is asking for, and — critically — what claims and permissions end up in the tokens:

scope=openid email profile
  • openid is what triggers OIDC behavior at all — without it, an "OAuth" provider has no obligation to return an ID token or expose a userinfo endpoint.
  • email, profile are OIDC standard scopes that add claims.
  • Provider-specific scopes (https://www.googleapis.com/auth/calendar.readonly, repo on GitHub) request actual API access, independent of identity.

Scopes are a request, not a guarantee — the authorization server (and the user, at the consent screen) can grant fewer than what was asked for. Always check the scope actually returned in the token response before assuming an API call will succeed.

OIDC discovery removes the need to hardcode endpoint URLs. Every compliant provider publishes a document at a fixed path:

GET https://accounts.google.com/.well-known/openid-configuration

This returns authorization_endpoint, token_endpoint, jwks_uri, userinfo_endpoint, and supported scopes/algorithms in one JSON document. Most OIDC client libraries fetch this once at startup and cache it — worth knowing when you're debugging why a library made a request to a URL you never typed.

Social login in practice: what actually goes wrong

Three things cause the overwhelming majority of social-login bugs, and none of them are exotic:

redirect_uri mismatches. The redirect_uri in the initial authorization request must match, character-for-character, one of the URIs registered with the provider — including trailing slashes and http vs https. This isn't pedantry; it's the primary defense against authorization code interception: if an attacker could register or predict an unlisted redirect URI, they could receive the code intended for a legitimate login. Test this explicitly across environments (localhost, staging, prod) — it's the single most common reason a social login integration works locally and breaks in staging.

Missing or mishandled state. Covered above, but worth restating as a checklist item: generate it fresh per request, tie it to the user's browser session (not just a global secret), and reject the callback if it doesn't match.

Account linking ambiguity. If a user signs up with email/password and later clicks "Sign in with Google" using the same email address, is that the same account? OAuth/OIDC give you no opinion on this — it's entirely your application's decision, and getting it wrong is a real account-takeover vector: if you auto-link accounts purely by matching email address, and the provider doesn't guarantee email_verified: true, an attacker can register an OAuth identity with a victim's email and get merged into their existing account. Only auto-link when the provider confirms the email is verified, and consider requiring an explicit "link accounts" confirmation step regardless.

Verifying an ID token: the checklist

Client libraries do this for you, but knowing the checks helps when one fails cryptically:

  1. Signature — verify against the provider's public keys, fetched from jwks_uri (from discovery). Keys rotate; cache with a TTL, don't hardcode.
  2. iss — matches the expected issuer exactly.
  3. aud — matches your client_id. This stops a token issued for a different app from being replayed against yours.
  4. exp — token hasn't expired. Allow a small clock-skew tolerance (a few seconds), not a large one.
  5. alg — matches what you expect (typically RS256). Reject tokens using alg: none — a classic JWT library footgun, and how several real-world OAuth libraries got CVEs.
  6. nonce (if you sent one in the auth request) — matches, to bind the ID token to this specific flow and block replay.

Debugging a broken social login flow

When "Sign in with Google" fails, work the flow in order rather than guessing:

  1. Confirm the redirect happened. Check browser devtools' Network tab for the initial redirect to /authorize — is the redirect_uri exactly what's registered? Is scope what you expect?
  2. Check the callback URL. Does it contain code and state, or does it contain error and error_description? Providers are usually explicit — redirect_uri_mismatch, invalid_client, access_denied — read the query string before reading logs.
  3. Reproduce the token exchange with curl, outside your app, to isolate whether the bug is in your code or in the provider config:
curl -X POST https://oauth2.googleapis.com/token \
  -d grant_type=authorization_code \
  -d code=PASTE_FRESH_CODE_HERE \
  -d redirect_uri=https://myapp.com/auth/callback \
  -d client_id=$CLIENT_ID \
  -d client_secret=$CLIENT_SECRET \
  -d code_verifier=$VERIFIER

Remember the code is single-use and short-lived — you'll need a freshly captured one each time you retry this. 4. Decode the ID token (jwt.io or equivalent) and manually check the claims list above before assuming your validation library is broken. 5. Check for stale local state — refresh tokens revoked provider-side (user removed app access in their Google account settings) will fail silently until you try to use them; your app should treat a invalid_grant on refresh as "re-authenticate," not as a bug.

Interview answer vs production answer

The interview answer is: "OAuth is for authorization, OIDC adds authentication, use Authorization Code flow with PKCE." That's correct and gets you through a screen.

The production answer includes things the interview answer skips:

  • Logout doesn't really log the user out of Google. Ending your app's session doesn't revoke the Google session or the tokens you hold. If you need actual revocation, call the provider's revocation endpoint explicitly, and separately decide whether "log out of my app" should also prompt "log out of Google" (usually it shouldn't — that's surprising and disruptive to users signed into multiple apps with the same Google account).
  • Refresh tokens can go stale for reasons outside your control — the user revoked access, the provider rotated signing keys in a way your cache didn't pick up, or the provider enforces refresh token expiry after a period of inactivity (Google does, after six months). Your token-refresh code path needs a defined "re-authenticate" fallback, not just a retry.
  • PKCE is not optional risk-reduction for public clients — treat it as mandatory, not a nice-to-have, per current OAuth 2.1 guidance.
  • state and nonce are not interchangeable even though both are opaque random strings: state defends the redirect itself (CSRF), nonce binds the ID token to the specific request (replay protection). Skipping either for "it's basically the same check" reintroduces the exact vulnerability class each was added to close.

FAQ

Is OAuth 2.0 an authentication protocol?

No. OAuth 2.0 is an authorization framework — it grants access to resources, and was never designed to reliably convey identity. Using raw OAuth for login (checking that an access token was issued, without OIDC's ID token) is a well-known anti-pattern sometimes called "pseudo-authentication."

Do I need OIDC if I'm only building an internal API?

Not necessarily. If there's no "log in with a third party" requirement and you control both the client and the resource server, a simpler session or JWT scheme (Part 2) may be all you need. OIDC earns its complexity when you need federated identity — letting users authenticate somewhere you don't control.

What's the difference between state and nonce?

state round-trips through the authorization redirect and protects against CSRF on the callback. nonce is embedded inside the ID token itself and protects against token replay. Use both when you're implementing OIDC login.

Can I decode a JWT ID token without a library?

Yes, structurally — split on ., base64url-decode the first two segments, you'll see the header and payload as JSON. But decoding is not validating. Always verify the signature, issuer, audience, and expiry before trusting any claim inside it.

Why does my access token expire so quickly?

Short-lived access tokens (often 1 hour or less) limit the blast radius if one leaks — an exposed token becomes useless quickly. The refresh token, which is longer-lived and more tightly protected, is what lets your app get a new access token without bothering the user.

Is it safe to store an access token in localStorage?

No, for the same reason covered for JWTs in Part 2: anything reachable by JavaScript is reachable by an XSS payload. Prefer a server-held session or an httpOnly cookie for tokens your frontend doesn't need to read directly.

What does openid in the scope list actually do?

It signals to the authorization server that this is an OIDC request, not a bare OAuth request — it's what causes an ID token to be issued alongside the access token. Omit it and you may get an access token with no standardized way to know who logged in.

Why did my redirect URI work locally but fail in production?

Registered redirect URIs must match exactly, including scheme and trailing slash. http://localhost:3000/callback and https://myapp.com/callback/ are different strings to the authorization server even if they're "the same route" to you — each environment needs its own exact registration.

Glossary

TermMeaning
Resource ownerThe user who owns the protected data
ClientThe application requesting access (your app)
Authorization serverIssues tokens; authenticates the user (Google, GitHub, Auth0)
Resource serverHosts protected data; accepts access tokens (the API)
Access tokenGrants access to a resource server; audience is the API
ID tokenJWT proving identity; audience is the client app itself
Refresh tokenLong-lived credential used to obtain new access tokens
ScopeSpace-delimited string requesting specific access/claims
PKCEProof Key for Code Exchange; protects public clients without a secret
stateCSRF defense on the OAuth redirect
nonceReplay defense embedded in the ID token
Discovery document.well-known/openid-configuration; publishes provider endpoints

The end-to-end mental model

                     ┌─────────────────────────┐
                     │   Authorization Server    │
                     │  (Google / GitHub / IdP)  │
                     └────────────┬─────────────┘
                                   │
        1. redirect w/ PKCE      │      2. login + consent
        (state, code_challenge)  │      happens HERE, not
                                   │      in your app
   ┌──────────┐  ◄───────────────┘  ───────────────►  ┌──────────┐
   │ Browser  │                                        │   User   │
   └────┬─────┘                                        └──────────┘
        │  3. redirect back: ?code=...&state=...
        ▼
   ┌──────────────┐   4. server-to-server exchange   ┌─────────────────┐
   │  Your Backend │ ───────────────────────────────►│ Token Endpoint   │
   │  (the Client) │ ◄───────────────────────────────│ (Auth Server)    │
   └──────┬────────┘   access_token, id_token,        └─────────────────┘
          │              refresh_token
          │  5. validate id_token (iss/aud/exp/sig)
          │  6. create YOUR session (Part 2's territory)
          ▼
   ┌──────────────┐
   │ Your Session /│
   │ JWT for user  │
   └──────────────┘

Everything above the dashed line into "Your Backend" is what this post covered. Everything below it — how that session gets stored, refreshed, and checked on every subsequent request — is exactly what Part 2 already walked through. OAuth and OIDC don't replace session management; they're the front door that decides who's allowed to open it.

Next up: now that a request arrives authenticated — via your own login or a federated one — how do you decide what that specific request is allowed to do? That's authorization policy, and it's where Part 4 picks up.

Series: Production Backend Systems

Part 3 of 3

About the author

Suriyaprakash Somu is a full-stack developer from Erode, Tamil Nadu, building production-ready business applications with React, Node.js, Fastify, PostgreSQL and MySQL. He focuses on Access Control, schema-based forms, and reliable backend workflows.