Overview
OAuth 2.0 is the backbone of modern delegated authorization. Most production compromises that get a CVE or a CISA alert did not come from protocol design failures, they came from implementation choices: the wrong grant type for the client, the wrong place to store a refresh token, scopes that were too broad, redirect URIs that were too permissive, or token validation that skipped a check.
The OAuth 2.0 specification is large (RFC 6749 plus a dozen supporting RFCs, with OAuth 2.1 in draft) and the failure modes are subtle. A developer who reads the spec and implements against it without having seen it exploited can build something that is technically correct and operationally broken. The right way to think about OAuth is not as a protocol to implement but as a security boundary to enforce: every choice in the implementation is a place where the boundary can be too permissive.
This article is the choices that actually matter. Grant type selection, token storage, scope design, redirect URI handling, token validation, and the operational practices that catch the residual risks. The right reading order is: understand the protocol, pick the grant type that fits the client, store tokens where the threat model says to, design scopes that match the resources, validate tokens strictly, and treat the implementation as something to monitor and audit.
How it works
OAuth 2.0 is a delegation protocol. The user (resource owner) authorizes a client (the application) to act on their behalf at a resource server (the API), with the authorization server (the IdP) mediating the exchange. The client does not see the user's password; it receives a token that represents an authorization grant. The token is presented to the resource server, which validates it and returns the requested resource. The whole exchange happens through HTTP redirects and bearer tokens.
The grant types are the different ways the client obtains a token. Authorization Code is the most common: the user is redirected to the authorization server, authenticates, approves the requested scopes, and is redirected back to the client with an authorization code. The client exchanges the code for an access token (and optionally a refresh token) via a back-channel call. Authorization Code with PKCE (Proof Key for Code Exchange, RFC 7636) adds a one-time code verifier to the exchange that prevents authorization code interception; PKCE is now the default and required for any client, public or confidential. Implicit grant is deprecated in OAuth 2.1; it returned the access token directly in the URL fragment, which leaked it through browser history, referrer headers, and any log files. Client credentials is for service-to-service trust where there is no end user; the client authenticates itself and gets a token. Device authorization grant is for input-constrained devices (smart TVs, CLI tools) that cannot easily do a browser redirect.
Tokens come in two flavors: access tokens and refresh tokens. The access token is what the client sends to the resource server; it has a short lifetime (often 5-60 minutes) and a specific set of scopes. The refresh token is what the client uses to get a new access token when the old one expires; it has a long lifetime (often days to weeks) and is the higher-value credential. The two-token design is what makes short-lived access practical: the user does not have to re-authenticate every time the access token expires, but a stolen access token is useful only until it expires.
In practice
The right grant type for a web application that has a server side is Authorization Code with PKCE, with the client secret kept on the server side. The right grant type for a single-page application (SPA) that runs entirely in the browser is also Authorization Code with PKCE, but the implementation has to handle token storage without a server side. The right grant type for a mobile app is also Authorization Code with PKCE, with PKCE preventing authorization code interception by another app on the device. The right grant type for a server-to-server call (a backend calling an API on behalf of itself, not a user) is Client Credentials. The right grant type for an input-constrained device is Device Authorization. Implicit grant is not the right grant type for anything in 2024 and should be removed from any system that still supports it.
Token storage is the operational decision that gets compromised most often. For server-side applications, access tokens can live in memory or in a server-side session store; refresh tokens should be in a server-side session store, never in a cookie that the browser can read and never in localStorage. For SPAs, the right pattern is the backend-for-frontend (BFF) pattern: the SPA calls its own backend, the backend holds the tokens in a server-side session, and the browser only ever holds a session cookie. The wrong pattern is storing refresh tokens in localStorage or in a cookie accessible to JavaScript, both of which are exposed to any XSS in the application. Mobile apps should use the platform secure storage (Keychain on iOS, Keystore on Android) and never persist refresh tokens in plain-text files.
Scope design is the part that determines the blast radius of a token compromise. Coarse scopes (read, write, admin) give every client of a given type the same authority, which means a token leak compromises everything the client type can do. Resource-scoped, fine-grained scopes (orders:read, billing:refund, user.profile.update) limit the blast radius: a stolen token only does what that token was specifically authorized to do. The cost of fine-grained scopes is the work to design them and the work to enforce them on the resource server, but the operational benefit is that an incident has a smaller blast radius and the response is faster.
Redirect URI handling is the part where mistakes become account-takeover bugs. The redirect URI list on the client registration must be an exact-match allowlist (full URL including scheme, host, path, and query). Wildcards are not safe. Open redirectors on the registered URIs are not safe. Any deviation from exact-match gives an attacker a way to receive the authorization code and exchange it for a token. The same exact-match rule applies to logout URIs and any other redirect-based flow.
Common mistakes
The first mistake is implementing OAuth without PKCE. The PKCE step is one additional round trip with a code verifier and a code challenge, but it prevents authorization code interception, which is the most common attack against public clients. The performance cost is negligible; the security benefit is real. Any OAuth implementation in 2024 that does not include PKCE is a candidate for an incident.
The second is storing refresh tokens in the browser. localStorage, sessionStorage, IndexedDB, and any other client-side storage is accessible to any script running in the origin. A single XSS in the application reads every secret that storage holds. The right pattern for SPAs is the BFF pattern; the right pattern for native apps is platform secure storage; the right pattern for server-side apps is server-side session storage. There is no right pattern that involves the browser reading the refresh token directly.
The third is over-broad scopes. A scope that gives the client permission to do everything a user can do is a scope that turns a token leak into a full account takeover. The cost of fine-grained scopes is real but the operational benefit is that an incident has a smaller blast radius and is faster to recover from. The default-deny posture applies to scope design the same way it applies to network policy.
The fourth is skipping token validation on the resource server. An access token that arrives at the resource server has to be validated against the authorization server (signature, issuer, audience, expiration, scopes, revocation status). A resource server that treats the access token as a bearer string and answers any request that presents one is broken. Token validation is not optional and is not an optimization; it is the security boundary.
The fifth is logging tokens. Tokens in access logs, error logs, application logs, or anywhere else that survives a log retention cycle are tokens waiting to be exfiltrated. Log the token's metadata (jti, iat, exp, scope, client_id) but never the token itself. The same applies to authorization codes, refresh tokens, and any other short-lived credential that has access to resources.
Defensive guidance
Make PKCE the default for every OAuth flow. The cost is small and the protection against authorization code interception is significant. If you have an existing OAuth implementation without PKCE, the migration is adding the code verifier and code challenge to the authorization request and the code exchange; the protocol change is backward-compatible and the operational change is documented.
Use the BFF pattern for SPAs. The SPA calls its own backend, the backend holds the tokens, and the browser only ever holds a session cookie. This is the only pattern that gives the SPA the same security properties as a server-side application without giving up the SPA's interactive UX. Libraries like the OpenIddict BFF sample, the Auth0 SPA SDK with BFF, and the Duende BFF framework provide drop-in implementations.
Use fine-grained scopes. Design scopes that match resources (orders:read, orders:write, billing:refund) rather than roles (read, write, admin). Enforce scopes on the resource server, not just in the access token. The cost of fine-grained scopes is the design and enforcement work; the operational benefit is that a token leak has a small blast radius.
Validate tokens strictly on the resource server. Verify the signature against the authorization server's public keys, verify the issuer matches the expected authorization server, verify the audience matches this resource server, verify the token has not expired, verify the token has not been revoked (via introspection or a token blacklist), and verify the requested scopes match the token's scopes. Any check that is skipped is a check that an attacker can exploit.
Never log tokens. Log token metadata (jti, iat, exp, scope, client_id, ip, user_agent) but never the token itself. Apply the same rule to authorization codes, refresh tokens, and any other short-lived credential. Audit your logs periodically to confirm no tokens are being logged; a single line of code that writes a token to a debug log is a long-lived incident waiting to happen.
Treat the OAuth implementation as something to monitor and audit. Log every authorization request, every token exchange, every token refresh, and every token validation. Alert on unusual patterns: token exchanges from unexpected IPs, refresh attempts with revoked tokens, scope mismatches between the requested and the granted. The right operational model is to assume the OAuth implementation is being attacked and to instrument it to show you the attacks.