Authentication vs authorization — the distinction OAuth makes you internalize
OAuth's most important contribution is forcing you to learn the difference between two questions that sound similar:
- Authentication — who are you? Establishes identity. OIDC, SAML, FIDO answer this.
- Authorization — what are you allowed to do? Decides whether a specific action is permitted. OAuth 2.0 answers this.
OAuth was designed so that a third-party app (say, a calendar integration) could perform specific actions in another service (your Google Calendar) without the user handing over their Google password. Instead, the user grants the integration a scoped, revocable access token that lets it do narrow things ("read calendar events") on their behalf. The third-party never sees the password.
OpenID Connect later piggybacked on this to also answer the identity question, by adding an ID token to the response. That's why "Sign in with Google" works — it's OAuth carrying an OIDC payload.
The four roles in every OAuth flow
Resource Owner
The user who owns the data. You, when "Acme Calendar" asks to access your Google Calendar.
Client
The application asking for access. Has a registered client_id and (if confidential) a client_secret.
Authorization Server
The server that authenticates the resource owner and issues tokens. Operated by the platform whose data is being shared.
accounts.google.com (Google's auth server).Resource Server
The API that holds the protected data. Validates incoming access tokens and serves the data they're authorized for.
googleapis.com/calendar/v3 (the Calendar API).In small implementations the authorization server and resource server are the same system. Conceptually they're separate; in production at any scale they actually are.
The grant types — pick the right tool
OAuth doesn't define one login flow; it defines several, optimized for different deployment shapes. Most of the historical attacks against OAuth come from using the wrong one.
| Grant | Used by | How it works | Status |
|---|---|---|---|
| Authorization Code | Web apps with a backend | Browser gets a short-lived code via redirect, server exchanges it for tokens at the token endpoint using its client_secret. | PREFERRED |
| Authorization Code + PKCE | Single-page apps, mobile apps, anything without a server-side secret | Same as auth code, but the client proves it started the flow with a one-time secret (the PKCE verifier). Defeats stolen-code attacks on public clients. | PREFERRED for public clients |
| Implicit | (Was: SPAs) | Browser receives tokens directly in the redirect URL fragment. No backend involved. | DEPRECATED 2020 — tokens leak via referrers, browser history, logs. Use Auth Code + PKCE. |
| Resource Owner Password | (Was: legacy migrations) | App collects username + password and sends them to the auth server. Defeats the whole point of OAuth. | DEPRECATED 2020 — never use in new code. |
| Client Credentials | Service-to-service (no user involved) | Client authenticates itself with its client_id + client_secret. No user, no consent screen. For batch jobs, microservices. | Valid for machine-to-machine |
| Device Authorization (Device Code) | Smart TVs, CLIs, IoT — anywhere without a usable browser | Device shows a short code + URL. User opens that URL on their phone, types the code, approves. Device polls the token endpoint until approved. | Valid for device flows |
| Refresh Token | Long-lived sessions | Not a grant by itself — a token type returned alongside the access token. Used to mint new access tokens without re-prompting the user. | Valid; rotate refresh tokens for public clients |
Which grant should you use?
Three quick questions narrow it down.
The Authorization Code flow — what an exchange actually looks like
The most important grant. Worth knowing the redirects by heart.
https://accounts.google.com/o/oauth2/v2/auth?client_id=...&redirect_uri=https://my-app.com/cb&scope=openid email&state=rand123&response_type=code&code_challenge=...&code_challenge_method=S256client_id is real, redirect_uri matches the pre-registered allowlist, and prompts the user to log in (if not already signed in) and to consent ("Allow My-App to access your email?").https://my-app.com/cb?code=ABC123&state=rand123state matches what it sent (CSRF defense), then makes a server-to-server POST to Google's token endpoint with code=ABC123, client_id, client_secret, code_verifier, grant_type=authorization_code.access_token, id_token (a JWT — that's OIDC), refresh_token, expires_in.Authorization: Bearer <access_token> on every request until it expires; uses refresh_token to mint new ones.Scopes — the "what" of authorization
Every access token is scoped: it permits certain operations and not others. The client requests a list of scopes in step 1; the user sees those scopes on the consent screen; the access token is issued with exactly that scope set (or a narrower one). The resource server checks the scope before serving each request.
Common scope examples:
openid— requests an ID token (OIDC).email profile— basic identity claims.https://www.googleapis.com/auth/calendar.readonly— read calendar events.https://www.googleapis.com/auth/calendar.events— read AND write events.repo— on GitHub, full repository access (overly broad; many apps request it when they only needrepo:status).
Principle of least privilege applies: ask for the narrowest scope you can actually use. Apps that ask for everything teach users to consent without reading.
The pitfalls that keep producing CVEs
- Loose
redirect_uriallowlists. If the auth server accepts any redirect_uri matchinghttps://my-app.com/*, an attacker who finds an open-redirect onmy-app.comcan steal the authorization code. Always use exact-match URI registration. - Missing
stateparameter. Without state validation, an attacker can trick a logged-in user into linking the attacker's account. The state acts like a CSRF token for the OAuth flow. - Implicit grant in SPAs. Tokens in URL fragments leak via browser history, server logs, Referer headers, and (historically) browser extensions. Auth Code + PKCE replaced this for a reason.
- Confused-deputy via
id_token. Some apps use an OIDC ID token as a bearer token to the wrong audience. ID tokens are for the client to verify identity; they are not API access tokens. Don't confuse the two. - Skipping
audclaim validation on ID tokens. If the server doesn't check thataudis its own client_id, it'll accept tokens issued to different clients on the same IdP. Cross-app account hijack. The JWT page covers this in detail. - No refresh token rotation. A leaked refresh token gives perpetual access. Refresh tokens for public clients should be one-use; the auth server issues a new one with each refresh and revokes the previous.
- Overly broad scopes by default. If your library requests every scope it might ever need, you've taught users to grant everything. Consent fatigue is a real attack surface.
Notable incidents
- Pawn Storm OAuth phishing (2017, ongoing). Nation-state attackers built malicious OAuth apps named things like "Google Defender" and asked targets to grant them full mailbox scope. Once consented, the malicious app had legitimate API access — password rotations don't revoke OAuth grants.
- Microsoft Teams "Storm-0558" (2023). Forged OIDC tokens used to access Outlook/M365. Root cause involved a leaked signing key; the OAuth/OIDC layer accepted them.
- GitHub OAuth app phishing (recurring). Attackers make a benign-looking OAuth app, get developers to authorize it with
reposcope, then exfiltrate every private repository the developer can see. - Slack and Discord token theft via malicious browser extensions. Bearer tokens stored client-side are stolen by extensions that can read the local storage of those sites. OAuth itself isn't broken — the storage of long-lived bearer tokens in environments where untrusted code runs is the problem.