OAuth & OIDC

How OAuth 2.0 Actually Works

OAuth 2.0 explained without the marketing copy — what the redirects are really doing, where the tokens come from, what PKCE protects against, and the parts the spec doesn't make obvious.

Emilian GheoneaMay 10, 2026 9 min read

OAuth 2.0 is one of those protocols where the official explanations make perfect sense to people who already understand it and almost no sense to anyone else. The flow diagrams skip steps. The terminology drifts between specs. Half the questions you'd ask ("where does the access token come from? why is there a code first?") aren't directly answered by RFC 6749 — they're answered by understanding what attack the protocol is trying to prevent.

This is the explanation we wish we'd had: what each step does, why it exists, and what breaks if you skip it.

The problem OAuth solves

You're building an app. The app needs to read the user's Google Calendar. You have two basic options.

Option A: Ask the user for their Google password. Use it to log into Google. Read the calendar.

This is bad for reasons that should be immediately obvious. Your app now has a Google password that works for everything Google — Gmail, Drive, Photos, YouTube. The user can't revoke just your access without changing their entire Google password. You're also liable for storing a credential that should never have left Google.

Option B: Have Google authenticate the user directly, and hand your app a scoped, revocable token that only works for the calendar API and only for this user.

This is OAuth 2.0. The whole protocol is mechanics to make Option B safe.

The four roles

OAuth defines four parties. Most of the confusion in the spec comes from not pinning down which one is which:

  • Resource Owner — the user. They own the Calendar.
  • Client — your app. The thing that wants to call Google's API.
  • Authorization Server — Google's identity layer (accounts.google.com). Issues tokens.
  • Resource Server — Google's Calendar API. Accepts tokens.

In a lot of real systems, the Authorization Server and Resource Server are run by the same company (Google). In some systems they're distinct (a third-party identity provider issuing tokens that your own backend API consumes). The protocol doesn't care; it treats them separately.

The Authorization Code flow, end to end

This is the flow you almost certainly want. Modern OAuth has effectively deprecated the alternatives (implicit, password grant) for security reasons we'll get to.

Rendering diagram…

Eight steps. Three round-trips. Two different credential types in transit. It's a lot. Each piece is there for a reason.

Step 1: the authorization request

The client redirects the browser to the authorization server with a URL like:

https://accounts.google.com/o/oauth2/v2/auth
  ?client_id=YOUR_CLIENT_ID
  &redirect_uri=https://your-app.com/callback
  &response_type=code
  &scope=https://www.googleapis.com/auth/calendar.readonly
  &state=randomly-generated-csrf-token
  &code_challenge=BASE64URL(SHA256(code_verifier))
  &code_challenge_method=S256

A few of these parameters are critical and easy to get wrong.

redirect_uri must exactly match one of the URIs you've pre-registered with the auth server. This is the only thing stopping an attacker from sending the user through a legitimate authorization request and then catching the callback at an attacker-controlled URL. If the auth server lets unrelated redirect_uri values through, every OAuth client they protect is vulnerable.

state is your CSRF defense. The client generates a random value, stores it (in a cookie or a session), and includes it in the request. The auth server echoes it back on the callback. If you don't validate that the returned state matches what you stored, an attacker can trick a logged-in user into linking the attacker's account to your app. This bug is shockingly common.

code_challenge and code_challenge_method are PKCE, which we'll explain in a moment.

Step 2: the user signs in and consents

This step happens entirely on the authorization server. Your app has zero visibility into it. The user might already have a session, they might need to MFA, they might be a different person than you expect — all of that is the auth server's problem.

The consent screen is more interesting than it looks. The user is being asked to authorize the specific scopes you requested. If you asked for calendar.readonly, the user authorizes only that. If you later need write access, you do another authorization round-trip with the expanded scope. Don't ask for calendar (full access) when you only need read — both because it's bad practice and because users will (correctly) decline.

Step 3: the callback with a code

After the user consents, the auth server redirects the browser back to your redirect_uri with a code query parameter:

https://your-app.com/callback?code=4/0AVMBs...&state=randomly-generated-csrf-token

Critically: this code is not an access token. It's a short-lived (usually under 10 minutes), single-use credential. You can't use it to call the Resource Server directly.

This is the part beginners always ask about: why are there two steps? Why not just send the access token here?

Because the URL the browser is being redirected to is visible in:

  • The browser's address bar
  • The browser history
  • HTTP server logs (access_log lines)
  • Any analytics that capture document.referrer
  • Browser extensions

If the access token were in this URL, anyone who got the URL would get the token, and the token might be valid for an hour. The code is a bearer of the right to get a token — but only when paired with the client's credentials, and only once.

Step 4: code exchange

The client now makes a back-channel POST to the auth server:

POST /token HTTP/1.1
Host: oauth2.googleapis.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=4/0AVMBs...
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&redirect_uri=https://your-app.com/callback
&code_verifier=ORIGINAL_VERIFIER

This request comes from your server, not the browser. The client secret never touches the user's machine. The authorization server validates:

  • The code exists and isn't expired.
  • The code hasn't been used before.
  • The client_id matches the one the code was issued for.
  • The client_secret is correct.
  • The redirect_uri matches the one used in the authorization request.
  • The code_verifier hashes to the code_challenge from step 1.

If everything checks out, the auth server responds with the actual tokens:

{
  "access_token": "ya29.A0AfH6SMB...",
  "expires_in": 3600,
  "refresh_token": "1//0gB...",
  "scope": "https://www.googleapis.com/auth/calendar.readonly",
  "token_type": "Bearer"
}

The access token is what you put in the Authorization: Bearer ... header when calling the Resource Server. The refresh token is what you use to get a new access token after the current one expires.

PKCE: the thing that makes this safe for SPAs and mobile

PKCE (Proof Key for Code Exchange) sounds like an alphabet-soup detail. It is, in fact, the single most important security change to OAuth in the last decade.

The original Authorization Code flow assumed the client could safely keep a client_secret. That assumption holds for server-side web apps. It does not hold for:

  • Single-page apps (JS in the browser — anyone can read the secret).
  • Mobile apps (decompile the binary, extract the secret).
  • Desktop apps (same).

PKCE removes the dependency on the client secret. Instead:

  1. The client generates a high-entropy random string: the code verifier.
  2. It hashes the verifier (SHA-256, base64url) to produce the code challenge.
  3. It sends the challenge in the authorization request (step 1).
  4. It sends the verifier in the code exchange (step 4).
  5. The auth server hashes the verifier and compares it to the challenge it stored.

The attack PKCE defends against is "code interception on a public client." Picture a malicious app on a phone registered for a custom URL scheme that the legitimate app also uses. When the OAuth callback fires, the malicious app might intercept it. Without PKCE, the malicious app then has a code and (since public clients have no secret) can exchange it for tokens. With PKCE, the malicious app has a code but doesn't have the verifier — so the exchange fails.

You should use PKCE for every OAuth client, public or confidential. It costs almost nothing and it protects against entire categories of bugs.

Refresh tokens and why they're sometimes a trap

Access tokens expire fast — usually an hour. The refresh token is how you get a new one without making the user log in again:

POST /token
grant_type=refresh_token
&refresh_token=1//0gB...
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET

The response is a new access token, sometimes a new refresh token, and you're back in business.

The thing nobody tells you: refresh tokens are very valuable credentials. They often don't expire (or expire only after months of disuse). They're not bound to the user's password — rotating the password doesn't revoke them. If you store a refresh token in localStorage, you've given any XSS attacker the equivalent of permanent persistent access to that user's resources.

A few mitigations worth knowing:

  • Refresh token rotation. Each time you use a refresh token, the auth server gives you a new one and invalidates the old. If the same refresh token is used twice, the auth server detects the replay and revokes everything. Auth0, Okta, and most modern IdPs support this — turn it on.
  • Don't store refresh tokens in the browser. Keep them server-side, in an HttpOnly cookie session, or in a backend service. The browser only ever sees access tokens.
  • Short-lived refresh tokens. "Doesn't expire" is a choice; you can configure them to expire after hours or days for sensitive scopes.

The flows you should never use

OAuth 2.0 defines several grant types. Three of them are deprecated, dangerous, or both:

Implicit flow (response_type=token) sends the access token directly in the redirect URL — same URL we just discussed as a bad place for credentials. The Implicit flow exists because PKCE didn't, and SPAs couldn't safely do the code exchange. PKCE fixed that. Use Authorization Code + PKCE instead. OAuth 2.1 formally removes implicit.

Resource Owner Password Credentials (ROPC) has the client take the user's username and password directly and hand them to the auth server. This defeats the entire point of OAuth. It exists for migrating legacy systems. If you find it in a tutorial in 2026, stop reading the tutorial.

Client Credentials is a different beast — it's for service-to-service auth where there is no user. It's fine for what it does. Just don't use it for user authentication.

OAuth is not authentication

A common misuse: "we have OAuth login, so we have authentication."

OAuth 2.0 is an authorization protocol. It tells the client: "this user authorized you to access these resources." It does not, in itself, tell you who the user is. The fact that the user successfully logged into Google to get the token doesn't transit to your application unless you do extra work.

That extra work is OpenID Connect (OIDC). OIDC sits on top of OAuth and adds a id_token — a JWT with claims about the user (their ID, email, name). When you "log in with Google," you're using OIDC, not raw OAuth.

If your goal is authentication, ask the auth server for an OIDC id_token (request scope=openid) and verify it properly (signature, issuer, audience, nonce, expiration). The access_token is for calling the resource server; the id_token is for knowing who the user is.

We'll dig into OIDC, JWT verification, and the things that go wrong with both in JWT Explained for Developers.

A short checklist

If you're implementing an OAuth 2.0 client today, the non-negotiables:

  • Use Authorization Code flow + PKCE.
  • Pre-register exact redirect URIs. No wildcards.
  • Generate and validate state on every authorization request.
  • Verify id_token signatures and claims if you're doing login.
  • Store refresh tokens server-side, never in browser storage.
  • Turn on refresh token rotation if your IdP supports it.
  • Request the minimum scopes. Add more later only when you need them.

OAuth done right is invisible. Users don't think about it; engineers don't get paged about it. Most OAuth incidents come from a missed state validation, a too-permissive redirect_uri, or a refresh token sitting in the wrong place. All of those are preventable by following the checklist above.

Written by

Emilian Gheonea

Senior Blockchain & Full-Stack Software Engineer. I build EmbedAuth — an embeddable authentication platform for SaaS — and write about the auth problems most teams hit too late.