Hotshard
Security fundamentals

OAuth 2.0 & OIDC

Sign in with Google, or let an app touch your data — without handing over your password.

A photo-printing app wants to reach into your Google Photos and pull out the shots you want printed. You'd like to let it. But the only way you know to prove those photos are yours is your Google password, and there is no way you're typing that into some print shop's website. Hand it over and they can read your Gmail, empty your Drive, and change your password to lock you out. You want to grant one narrow thing: read my photos, nothing else, and let me take it back later. That's the problem OAuth 2.0 solves. It's a protocol for delegated access: letting app A act on your behalf at service B, with your permission, without ever showing A your B password. This page builds that flow step by step: the four parties, the redirect-and-consent dance, the one-time code, and the token exchange behind it. Then PKCE, the small addition that stops an attacker from stealing the code mid-flight. Then the distinction that trips up almost everyone: OAuth answers 'what may this app do,' and it is the wrong tool for 'who is this user.' Answering that second question is what OpenID Connect adds on top. This page assumes you already know how a login stays logged in — sessions, JWTs, access and refresh tokens (see /auth). It builds on that to explain the protocol that hands those tokens out across company lines.

~15 min read

Start here: letting an app act as you, without your password#

TL;DRthe 30-second version
  • OAuth 2.0 solves delegated access: you let one app reach your data at another service, without giving that app your password for the service.
  • The naive fix — hand the app your password — is a disaster: it can now do everything you can, forever, and the only way to revoke it is to change your password (which breaks everything else too).
  • Instead the app sends you to the service to log in and approve a narrow request. The service hands the app a scoped access token — permission to do just that one thing — and never reveals your password.
  • The flow has four parties (you, the app, the service's login server, the service's API) and runs as a redirect to consent, a one-time code, then a back-channel exchange of that code for the token. PKCE ties the code to the app that started the flow, so a stolen code is useless.
  • OAuth answers 'what may this app do' (authorization). It does not reliably answer 'who is this user' (authentication). Using a raw OAuth access token as proof of identity is the classic mistake — that job belongs to OpenID Connect's ID token.

You find a website that prints photo books from your Google Photos. To do its job it needs to read your photos. The obvious way to let it is to type your Google email and password into its login box. Stop and think about what that actually gives away.

Your Google password isn't a key to your photos. It's the key to everything: Gmail, Drive, your calendar, your saved payment methods, and the password itself. Hand it to the print shop and you've handed over all of it, not the one folder you meant to share. There's no way to say 'photos only.' There's no way to say 'just for today.' And to take the access back, your single option is to change your Google password, which logs you out of every other app and device at the same time. One narrow favour, and the only tool you have is a nuclear one.

What we actually needYou want to grant the print shop exactly one power — read my photos — and nothing else. You want to grant it without ever showing the print shop your password. And you want to revoke it later without disturbing anything else you use. So the grant has to be issued by the one party you do trust with your password: Google itself. You log in to Google, Google asks 'let this app read your photos?', and if you agree, Google hands the app a limited pass. The app never sees your password; it only ever holds the pass. The rest of this page builds that pass and the exchange that delivers it.

The four parties in every OAuth flow#

Before the flow makes sense, name the cast. Every OAuth exchange has exactly four parties, and once you can point at each one, the redirects stop looking like magic. Keep the photo example in mind as we name them.

  • Resource owner — you. You own the data (your photos) and you're the only one who can grant access to it.
  • Client — the app asking for access, the photo-printing site. 'Client' here means the third-party app, not a browser. It's the party that ends up holding the token and using it.
  • Authorization server — the part of Google that logs you in and runs the consent screen. It's the only party that ever checks your password, and it's the one that issues tokens.
  • Resource server — the API that actually holds your data, the Google Photos API. It doesn't know your password; it just accepts a valid token and serves the data the token is allowed to reach.

Notice the split inside Google: the authorization server (login and consent) and the resource server (the photos API) are separate jobs. The authorization server is the only one that touches your password. It vouches for you by issuing a token, and the resource server trusts that token instead of ever asking who you are. That separation is what lets the print shop get at your photos while your password stays with Google alone.

The authorization code flow, step by step#

Here's the whole exchange, the one OAuth calls the authorization code flow. It's the default and most secure OAuth flow, and it's what happens every time you click 'Connect your Google account' on some app. Follow it once and the rest of OAuth is detail.

You click 'Connect Google Photos'on the print shop
start the flow
App redirects your browser to Googleasking for the photos scope
you log in to Google, not the app
Google: log in + consent'Print shop wants: read your photos'
you approve
Google redirects back with a one-time codethe code lands in the browser
hand the code to the app
App → Google, direct: code → tokensback channel, browser not involved
trade it for the real token
App calls the Photos API with the access tokenreads your photos, nothing else
One click, then a scoped token

Two details in that flow do the real work. The first is that you log in at Google, on a Google page, not on the print shop. Your password travels to the one party that already has it and nowhere else. The print shop watches you disappear to Google and come back approved; it never sees what you typed.

The second detail is stranger: Google doesn't hand the token back through your browser. It hands back a one-time authorization code — a short, single-use string that is worth nothing on its own. The app then makes a second call, directly to Google from its own server, and trades that code for the actual access token. Why the extra step? Because the trip back through your browser is exposed. The code rides in a redirect URL, which can land in browser history, server logs, or a referrer header. If the real token travelled that way, anyone who glimpsed the URL would have your token. The code is deliberately useless without the second, private exchange.

Front channel vs back channelThe two paths have names. The front channel is anything that goes through your browser by redirect, where the print shop, Google, and your browser can all see the values as they pass. The back channel is a direct server-to-server call between the app and Google that your browser never touches. The rule that shapes the whole flow: only the harmless one-time code is allowed through the front channel; the valuable token is only ever handed over the back channel. That's why the token comes from a second exchange and not from the first redirect.

PKCE: stopping a stolen code#

The back-channel exchange has one built-in defence so far. When the app trades the code for a token, it also sends a client secret — a password the app registered with Google ahead of time. So even someone who steals the code can't exchange it; they'd need the secret too. That works for an app running on its own server, where a secret can stay hidden. It falls apart for a public client.

A public client is an app that can't keep a secret, because its code runs where the user (and an attacker) can read it: a mobile app on a phone, or a single-page app running entirely in the browser. Ship a client secret inside a mobile app and anyone can pull it out of the binary. So public clients run the flow with no secret. Now the only thing guarding the code exchange is the code itself, and on a shared device the code can be stolen. A malicious app on the same phone can register to catch the redirect and grab the code as it comes back. With no secret required, the attacker exchanges the stolen code for a token and walks off with your access.

PKCE (Proof Key for Code Exchange, say 'pixy') closes that hole without a stored secret. Instead of a fixed secret, the app invents a fresh random secret for each flow. At the very start, the app makes up a random string called the code verifier and keeps it in memory. It hashes the verifier with SHA-256 (a one-way function: easy to compute forwards, infeasible to reverse) to get the code challenge, and sends only the challenge in the first redirect. Google files the challenge away next to the code. Later, at the back-channel exchange, the app must present the original verifier. Google hashes it and checks that it matches the challenge it stored. Only the app that started the flow knows the verifier, so only that app can complete the exchange.

App invents a random code verifierkept in memory, never sent yet
hash it
Sends SHA-256(verifier) = challenge in the redirectfront channel, safe to expose
file it away
Google stores the challenge with the codewaits for a matching verifier
prove you started this flow
Back-channel exchange: app sends code + verifierGoogle re-hashes and compares
The verifier never leaves the app until the private exchange
PredictA mobile app runs the flow with PKCE. An attacker intercepts the authorization code the instant it comes back. To trade that code for a token, Google's token endpoint demands the code verifier — 32 random bytes whose SHA-256 hash the app sent earlier as the challenge. The attacker has the code and the challenge, but not the verifier. Roughly how many verifiers must they try, and what does that mean for the attack?

Hint: SHA-256 is one-way and the verifier is 256 bits of randomness — can the challenge be reversed, or only guessed?

The verifier is 32 random bytes, which is 256 bits of entropy — about 2^256, or 1.2×10^77, possible values. SHA-256 is one-way, so the challenge the attacker captured gives no shortcut back to the verifier; the only route is to guess. And 10^77 guesses is not a search anyone finishes before the sun burns out. So the intercepted code is worthless without the verifier, and the verifier never left the app's memory. That is the whole point of PKCE: it binds the authorization code to the one app that started the flow, so stealing the code in transit buys the attacker nothing.

PKCE started life as a fix just for public clients, but current guidance is to use it for every OAuth client, secret or not. It's a cheap, universal guard against code theft, so there's no reason to leave it off.

Scopes and the token itself#

Go back to the consent screen — the 'Print shop wants: read your photos' box. That specific permission is a scope: a named slice of access the app is asking for. Google publishes scopes like photoslibrary.readonly (read photos) or gmail.send (send mail as you). The app lists the scopes it wants in the first redirect, the consent screen shows you exactly those, and the token it gets back is stamped with only the scopes you approved. When the app calls the photos API, the resource server checks the token's scopes and refuses anything outside them. Ask for read-only photos and the same token can't touch your Gmail, even if the app tries.

This is least privilege in action: grant the narrowest set of powers that does the job, so a leak or a bug can only do that much damage. A good app asks for the fewest scopes it needs; a consent screen demanding far more than the task requires is a warning sign.

What the app actually holds is an access token. This is the same access token from /auth — a bearer token, meaning whoever holds it is treated as authorized, no questions asked. That's why it's short-lived and why it must ride over TLS (see /tls/sim): a bearer token sent over plain HTTP can be copied off the wire and replayed by anyone. Alongside it, OAuth issues a refresh token — the long-lived, revocable token the app uses to get a fresh access token when the short one expires, without dragging you back through the login screen. The mechanics of access-versus-refresh tokens are covered in /auth; here it's enough that OAuth is the protocol that hands them across company lines, from Google to the print shop.

The trap: OAuth is not a login system#

Now the distinction that catches almost everyone. Everything so far was about access: letting the print shop reach your photos. But you've also seen 'Sign in with Google' buttons that just log you in, with no data-sharing in sight. It's tempting to build that with the same access token, and it's a real security bug. Here's the reasoning, then the fix.

An OAuth access token is proof of permission, not proof of identity. It says 'the holder may read some photos.' It does not reliably say 'the holder is Alice,' and crucially it was not issued to your app as a statement about who the user is. So a login built on it works like this: the user connects Google, your app gets an access token, your app calls Google's API with it, sees the account is Alice's, and logs the user in as Alice. The hole is that the access token is a bearer token — anyone holding a valid one can present it.

That opens the token substitution attack. Suppose a malicious app also, legitimately, got an access token for Alice — she used that app too, and granted it access. That app now holds a valid Google access token for Alice. It replays that token to your app's login. Your app does what it always does: calls Google with the token, sees Alice, and logs the attacker in as Alice. Your app had no way to tell that the token was minted for a different app, because a raw access token carries no reliable 'who this was issued to' that your app checks. It was never meant to answer 'who is signing in here.'

OIDC adds an ID tokenOpenID Connect (OIDC) is a thin identity layer built on top of OAuth 2.0. It runs the same authorization code flow, but the authorization server returns one extra thing: an ID token. The ID token is a signed JWT (see /auth for JWT structure) that states who the user is (a sub claim, the user's stable id) and when they authenticated — and, decisively, who it was issued for (an aud, or audience, claim naming your app). Your app verifies the signature and checks that the audience is its own client id. A token minted for a different app fails that check. So the token substitution attack dies: the ID token proves identity to the one app it was issued for, and no one else's token will pass.

So the two tokens answer two different questions, and this is the line to hold onto. The access token is authorization — what the holder may do at the resource server. The ID token is authentication — who the user is, proven to the specific app that asked. 'Sign in with Google' is OIDC, and it works because of the ID token. If you ever find yourself using a raw OAuth access token to decide who a user is, you've reached for the authorization tool to answer an authentication question, and that's the bug.

What it costs#

OAuth buys you delegated access without password-sharing, and it isn't free. The price is moving parts. A simple login is one form post; an OAuth flow is a redirect out to another company's server, a consent screen, a callback, and a back-channel exchange — several round-trips, any of which can fail. Your app now depends on the authorization server being up: if Google's login is down, nobody can connect.

You also inherit token handling. Access tokens expire, so you store refresh tokens and run the refresh exchange when they do. Every token is a bearer token, so the entire scheme rests on TLS (see /tls/sim); drop to plain HTTP anywhere and a copied token is game over. And the flow has sharp edges an attacker probes — the code interception PKCE guards, and a cross-site request forgery on the callback that a state parameter guards (both in the under-the-hood beats below). None of this is hard once, but it's why the near-universal advice is to use a provider's SDK rather than hand-rolling the flow.

Under the hood

Four pieces the open path skips, for the reader who wants the mechanism whole.

Go deeperThe implicit flow, and why it's dead

Before PKCE existed, public clients had a problem: no secret to protect the code exchange. The early answer was the implicit flow, which skipped the code entirely and returned the access token straight in the redirect, in the URL fragment, through the front channel. That put the real token where the code interception problem lives — browser history, referrer headers, any script on the page — and it gave the authorization server no way to confirm which app was receiving the token. The OAuth 2.0 Security Best Current Practice (RFC 9700) now advises against it outright. The replacement is the authorization code flow with PKCE, for public clients and confidential clients (ones that can safely hold a secret, like a server-side app) alike.

Go deeperThe state parameter (CSRF on the callback)

The callback — Google redirecting back to your app with a code — is a request into your app that an attacker can try to forge, a cross-site request forgery (CSRF: tricking a logged-in user's browser into making a request they didn't intend). An attacker could splice their own authorization code into your callback URL and get your account linked to their identity. The defence is the state parameter: the app generates a random value at the start, sends it in the first redirect, and Google echoes it back on the callback. The app checks that the returned state matches the one it sent. A forged callback won't carry the right value, so it's rejected.

Go deeperOpaque tokens and introspection

An access token can be a self-describing JWT the resource server verifies on its own, or an opaque token — a random string that means nothing without asking the authorization server about it. For opaque tokens, the resource server calls a token introspection endpoint (RFC 7662): it hands over the token and asks 'is this still valid, whose is it, and what scopes does it carry?' This is the revocable counterpart to a self-verifying JWT. It costs a network call per check, but the authorization server stays the single source of truth, so revoking a token takes effect immediately instead of waiting for expiry.

Go deeperJWKS and key rotation

When ID tokens (and JWT access tokens) are signed, the app needs the authorization server's public key to verify the signature. Rather than baking the key into the app, the authorization server publishes its public keys at a JWKS (JSON Web Key Set) endpoint, and the app fetches them from there. Each key has an id, and every token names the key that signed it in a kid header field. So the server can rotate its signing keys — publish a new one, retire the old — and apps keep verifying without a redeploy: they just fetch the current set and match on the kid. Key rotation limits the damage if a signing key is ever exposed.

When to reach for which

OAuth and OIDC answer different needs, and the wrong one is a real mistake. A few rules that hold up.

  • You need delegated access — an app acting on a user's data at another service (read their photos, post to their calendar, list their repos): OAuth 2.0, authorization code flow with PKCE. This is the photo-printing case.
  • You need to log a user in — 'Sign in with Google/Apple/GitHub' with no data access required: OpenID Connect, and read the identity from the ID token. Do not authenticate a user from a raw access token.
  • You're building either flow yourself, by hand: don't. Use a provider or a vetted library (Auth0, Okta, Google's SDKs). The flows are subtle and the attacks — code interception, token substitution, callback CSRF — are easy to get wrong and hard to notice.
  • Public client (mobile or single-page app): always PKCE, never the implicit flow. The implicit flow is deprecated for exactly the token-exposure reason above.
Access token vs ID token, side by side

The two tokens look similar and do opposite jobs. Confusing them is the source of the identity bug, so here they are next to each other.

Access token (OAuth)ID token (OIDC)
AnswersWhat may the holder do?Who is the user?
PurposeAuthorizationAuthentication
Issued forThe resource server / APIThe specific client app (aud claim)
Who reads itThe resource server, on each API callThe client app, once at login
FormatOpaque string or JWTAlways a signed JWT
Never use it to…Identify the userCall a resource API
In the wild
  • Google runs OIDC on top of OAuth 2.0: 'Sign in with Google' reads identity from the ID token, while access tokens grant scoped reach into Gmail, Drive, and the Photos API. The authorization server and the resource APIs are the split described above, made concrete.
  • GitHub's OAuth apps are the delegated-access case in its purest form: a CI service or a code-review bot asks for scopes like repo or read:org, and acts on your repositories with a scoped token — never your GitHub password.
  • Auth0, Okta, and Clerk are managed authorization servers: they run login and consent, issue the tokens, publish the JWKS endpoint, and hand you SDKs, so an app gets OAuth/OIDC without building the flow or the attacks-avoided list itself.
  • Slack, Stripe, and Zoom 'Connect' buttons are OAuth authorization code grants: you approve a scoped grant once, and the app holds a refresh token to keep acting on your account until you revoke it.
Pitfalls & gotchas
Can I just use the access token to log the user in?

No — that's the classic mistake. An access token proves permission, not identity, and it's a bearer token that could have been issued to a different app entirely. Building login on it opens the token substitution attack: a token minted for app B gets replayed to your app, and your app logs the attacker in as the victim. Use OpenID Connect and read identity from the ID token, which is issued for your app specifically (the aud claim) and verified by signature. Access token for calling APIs; ID token for knowing who signed in.

Is OAuth authentication or authorization?

OAuth 2.0 is authorization — delegated access to resources. It says what an app may do on your behalf, not who you are. The confusion comes from 'Sign in with Google,' which feels like authentication but is really OpenID Connect (an identity layer) running on top of OAuth. If a page says 'OAuth login' and means real user authentication, it almost always means OIDC underneath.

Why not just return the token in the first redirect and skip the code?

That was the implicit flow, and it's deprecated. The first redirect goes through the front channel — your browser, browser history, referrer headers, any script on the page — so a token returned there is exposed to anyone who can see the URL, and the authorization server can't confirm which app received it. The authorization code flow returns a useless one-time code through the front channel and delivers the real token only over the private back channel. Use it with PKCE for every client.

Do I still need PKCE if my app has a client secret?

Current guidance says yes, use PKCE everywhere. The client secret protects the back-channel exchange, but PKCE additionally binds the authorization code to the exact flow that started it, closing code-interception attacks that a secret alone doesn't cover. It's cheap and adds a layer, so the recommendation is to apply it to confidential clients too, not just public ones.

QuizYour app lets users 'Sign in with Google.' A security reviewer asks how you verify the user's identity on each login. Which answer is correct — and which one is the token substitution bug?

  1. Call Google's API with the access token, see whose account it is, and log that user in.
  2. Read the user's identity from the OIDC ID token, after verifying its signature and that the audience claim is your own client id.
  3. Trust whatever user id the app's front-end sends back after the redirect.
  4. Any of these — as long as Google returned a valid token, the user is authenticated.
Show answer

Read the user's identity from the OIDC ID token, after verifying its signature and that the audience claim is your own client id.Only the ID token answers 'who is this user' safely. It's a signed JWT issued for your app specifically — you verify the signature and check that the aud claim names your client id, so a token minted for a different app is rejected. Choice 1 is the token substitution bug: an access token is a bearer token that proves permission, not identity, and it may have been issued to another app entirely, so an attacker can replay one and be logged in as the victim. Choice 3 trusts an unauthenticated value from the browser. Choice 4 is the whole misconception this page exists to kill.

In an interview

OAuth shows up in interviews as 'how does Sign in with Google work' or 'how would you let a third-party app access user data.' The strong answer walks the flow and names the OAuth-versus-OIDC line, instead of waving at 'we use OAuth.'

  • Name the four parties (resource owner, client, authorization server, resource server) and walk the authorization code flow: redirect to consent, one-time code back, back-channel exchange for the token. Interviewers notice when you know why the code comes back and not the token.
  • Explain PKCE unprompted for a mobile or single-page app: public clients can't hold a secret, the code can be intercepted, and the verifier/challenge pair binds the code to the app that started the flow. Mention that the implicit flow is deprecated.
  • Draw the OAuth-versus-OIDC line clearly: access token is authorization (what you may do), ID token is authentication (who you are). State the trap — never authenticate a user from a raw access token — and name the token substitution attack it prevents.
  • Mention scopes as least privilege, the state parameter as CSRF defence on the callback, and that every token is a bearer token that demands TLS. Those three signal you've shipped an integration, not just read about one.
References
References

Feedback on this topic →