A calendar app wants to read your Google Calendar. It should never see your Google password, not even once to throw it away. OAuth 2.0 is the protocol that makes that possible: your Google account issues the calendar app a token that lets it read your calendar and nothing else, and the app never touches your credentials.
That’s delegated authorization, and it’s a different problem from authentication. JWT vs session authentication covers how a server decides who you are. OAuth answers a narrower question: can this other application act on your behalf, and for what. Most OAuth mistakes start when people mix the two up.
OAuth defines several “grant types”, different recipes for getting a token, each shaped for a different kind of client. Pick the wrong one and you end up with access tokens sitting in a browser’s address bar, or a mobile app that embeds a client secret nobody can keep secret.
The four roles in every OAuth flow
- Resource owner: you, the user who owns the calendar.
- Client: the calendar app asking for access.
- Authorization server: Google’s login and consent screen, the thing that issues tokens.
- Resource server: the Google Calendar API, which accepts the token and returns data.
The client never handles the resource owner’s password. It gets an access token from the authorization server and presents that token to the resource server. A grant type is how the client gets that token in the first place, and the right one depends on what kind of app it is and whether a human is involved.
Authorization code with PKCE: the default for anything with a browser
This is the flow for a web app, a single-page app, or a mobile app: anything that can open a browser and receive a redirect back. It’s the only grant type OAuth 2.1 keeps for user-facing logins, and it requires PKCE (Proof Key for Code Exchange, pronounced “pixy”).
The client generates a random
code_verifier, hashes it to acode_challenge, and sends the user’s browser to the authorization server:1 2 3 4 5 6 7 8GET /authorize? response_type=code& client_id=calendar-app& redirect_uri=https://app.example.com/callback& scope=calendar.readonly& state=xk3f9a2b& code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM& code_challenge_method=S256The user logs in and approves the
calendar.readonlyscope on Google’s own page. The client never sees the password.The authorization server redirects back to
redirect_uriwith a short-livedcodeand the samestatevalue the client sent.The client checks that
statematches what it generated (this is what stops a CSRF attack on the callback), then exchanges the code at the token endpoint, proving it holds the original verifier:1 2 3 4 5 6POST /token grant_type=authorization_code& code=SplxlOBeZQQYbYS6WxSbIA& redirect_uri=https://app.example.com/callback& client_id=calendar-app& code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXkThe authorization server hashes
code_verifier, compares the result to thecode_challengefrom step 1, and if they match, returns an access token (and usually a refresh token).
PKCE exists because a public client, such as a SPA or a mobile app, has no way to hold a secret. Anyone can decompile the app or read its JavaScript. Without PKCE, whoever intercepts the authorization code on the redirect can trade it for a token. The verifier stays on the client until the final token exchange, so an intercepted code alone is useless. The current OAuth 2.1 draft (draft-ietf-oauth-v2-1-17, still an IETF Internet-Draft, not a published RFC) requires PKCE from every client by default, with one narrow exception: a confidential client can skip it only if the authorization server has reasonable assurance the client correctly implements OpenID Connect’s nonce mechanism, and even then the draft still recommends using it anyway. Only the S256 challenge method is allowed; plain is forbidden.
Client credentials: machine-to-machine OAuth with no user
A nightly batch job calls your billing API. Nobody is there to log in or approve a consent screen. One backend service proves its own identity to another.
| |
You get back an access token and no refresh token, because there’s no user session to extend. When the token expires, the client calls /token again with the same credentials. The secret travels in the request body, which is one reason the token endpoint only ever runs over TLS. This grant only works for confidential clients, meaning something that can store a secret safely, like a backend service with locked-down environment variables. Never a browser or a mobile app.
Refresh tokens: staying logged in without asking again
A 15-minute access token is short-lived on purpose, so the client needs a way to renew it without running the whole login flow again. The authorization code flow issues a refresh token alongside the access token for this:
| |
Treat refresh tokens as more sensitive than access tokens. They live longer, and a leaked one can mint fresh access tokens indefinitely. The usual defense is rotation: every refresh issues a new refresh token and invalidates the old one. If an already-used refresh token shows up again, the authorization server revokes the whole chain. A reused token means it was stolen, and the real client and an attacker are now racing to use it.
Device authorization grant: OAuth for TVs and CLI tools
A TV, a CLI tool, or a smart speaker can show text but can’t open a redirect-capable browser, and nobody wants to type a password on a remote. The device authorization grant (defined separately in RFC 8628, which OAuth 2.1 references as an extension rather than a core grant) moves the browser step to a second device:
- The device requests a device code and shows the user a short code plus a URL: “Go to
example.com/deviceand enterWDJB-MJHT.” - The user opens that URL on their phone or laptop, logs in, and enters the code.
- Meanwhile, the device polls
/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_code. It gets “authorization pending” back until the user finishes, then an access token.
This is the flow behind gh auth login and the “Activate device” screens on streaming boxes.
What OAuth 2.1 removes: the implicit and password grants
Two older grant types still show up in tutorials. Don’t use them in anything you build.
Implicit grant (response_type=token) returned the access token directly in the URL fragment after the redirect, skipping the code exchange. A token in the fragment ends up in the browser’s address-bar history, and any script running on the page, including a third-party one the app didn’t write, can read it straight off window.location. It also arrives with no refresh token, so there’s no way to extend or revoke it short of waiting for it to expire. The OAuth 2.1 draft omits the implicit grant entirely: it’s no longer part of the specification.
Resource Owner Password Credentials (ROPC) had the client collect the user’s actual username and password and send them to the token endpoint. That defeats the reason OAuth exists, which is that the client never sees the password. Both the OAuth 2.1 draft and the OAuth Security BCP drop it.
OAuth 2.1 keeps exactly three core grants: authorization code, refresh token, and client credentials, with the device grant in its own spec. If a library’s example code uses implicit or ROPC, the example is older than this consensus. Use authorization code with PKCE instead, even for a “trusted” first-party app.
OAuth vs OpenID Connect: why OAuth is not login
OAuth was built to authorize access to a resource, not to establish identity. An access token proves “this client can call this API with this scope”. It says nothing verified about who the user is. And if you don’t check the aud claim, nothing stops a resource server from accepting a token minted for a different API than the one it’s protecting.
“Sign in with Google” works because of OpenID Connect (OIDC), a thin identity layer on top of OAuth’s authorization code flow. OIDC adds an id_token, a signed JWT with standardized claims like sub and email, alongside the access token. That gives the client something it can verify as “this is who logged in.” If your goal is letting users sign in with their Google account, you want OIDC’s id_token, not the raw access token OAuth hands you for calling an API.
Common OAuth mistakes that leak codes and tokens
- Skipping
state. Without it, an attacker can start their own authorization flow, trick a victim into completing it, and have the victim’s browser deliver the attacker’s authorization code into the victim’s session. That’s a CSRF on the OAuth callback itself. - Loose
redirect_urimatching. Register the exact URI and require an exact match at the authorization server. A prefix match or a wildcard turns the redirect into an open redirect that leaks codes and tokens to an attacker-controlled page. - Putting a client secret in a SPA or mobile app. There is no secure place to put it, which is exactly the gap PKCE closes for public clients. If you’ve shipped a secret inside client-side JavaScript or an APK, treat it as already public.
- Checking only the token’s signature, not its claims. A valid signature only proves the authorization server issued the token. Also check
exp, andaudif more than one API accepts tokens from this authorization server. An unchecked audience lets a token meant for API A work against API B.
Which OAuth grant type to use
| Situation | Grant type |
|---|---|
| Web app, SPA, or mobile app signing in a user | Authorization code + PKCE |
| Backend service calling another API, no user involved | Client credentials |
| Extending a session past the access token’s expiry | Refresh token (with rotation) |
| CLI, TV, or other browser-less device | Device authorization grant |
| Anything suggesting implicit or password grants | Don’t. Use authorization code + PKCE instead |
Most real systems combine two or three of these: authorization code with PKCE to log a user in, refresh tokens to keep them logged in, and client credentials for background jobs that don’t act for any user. If your “OAuth problem” is really “how do I know who’s logged in”, look at OIDC’s id_token first. And if the only API is your own backend, check whether plain sessions are simpler than running a full OAuth flow against yourself.