OAuth2 Flows Explained: Authorization Code, PKCE, Client Credentials, and When to Use Each

A developer implementing “log in with Google” copies the authorization code flow from a tutorial, gets it working, and moves on without ever understanding why that specific flow was the right one, or what would have gone wrong with a simpler-looking alternative. This works fine until the same developer later needs to build a server-to-server integration with no user involved at all, copies the same authorization code flow because it’s the only OAuth pattern they know, and spends a confusing afternoon wondering why there’s no user to redirect to a login screen in a process that’s never supposed to involve a human at all.

OAuth2 isn’t one flow, it’s a small family of related flows, each built for a genuinely different situation, and picking the wrong one isn’t just inelegant, it can mean genuinely worse security for the specific problem actually being solved. Understanding what each flow is actually for, not just how to copy its steps, is what turns OAuth2 from a cargo-culted checklist into something a developer can reason about directly.

What OAuth2 Actually Solves

OAuth2 solves delegated authorization: letting an application access a user’s data on another service without that user ever handing over their actual password to the requesting application. When a photo-printing app asks to access a user’s Google Photos, OAuth2 is what lets that happen without the user typing their Google password directly into the photo-printing app at all, keeping the user’s actual credentials known only to Google, while the photo-printing app receives a limited, scoped, revocable token instead.

This is a meaningfully different problem from authentication, proving who a user actually is. OAuth2 on its own says nothing about identity at all; it only grants an access token representing permission to act on a user’s behalf within some scope, without necessarily confirming who that user is in any verifiable way the requesting application can trust directly. This distinction gets blurred constantly in casual discussion, “OAuth login” is common shorthand, but the identity layer developers actually rely on for login flows is OpenID Connect, a protocol built directly on top of OAuth2 specifically to add a verifiable identity token to the exchange, covered in more detail later in this piece.

Public vs Confidential Clients: The Distinction Everything Else Depends On

Nearly every OAuth2 flow decision traces back to one basic classification: can this specific application keep a secret confidential, or not. A traditional server-side web application, where all the sensitive logic and any client secret live on a server the end user never has direct access to, is a confidential client. A single-page application running entirely in the browser, a native mobile app whose compiled binary any user can inspect or decompile, or a command-line tool distributed to end users are all public clients, since anything embedded in code that ships to an end user’s device or browser can’t genuinely be kept secret no matter how it’s obscured.

This distinction determines which flows are actually appropriate. Client credentials flow requires a confidential client, since it depends entirely on a secret that must stay hidden. The authorization code flow with PKCE was specifically designed to work safely for public clients, which is exactly why it’s become the universal recommendation across both client types rather than maintaining two separate secure patterns for two different situations.

The Authorization Code Flow: The Standard for Server-Side Apps

This is the flow most people mean when they say “OAuth” without further qualification, and it’s built for applications with a server-side backend capable of keeping a secret confidential. The flow runs in several distinct steps, and understanding each one matters more than memorizing the sequence.

First, the application redirects the user’s browser to the authorization server, Google, GitHub, whichever provider is being integrated with, including the application’s client ID, the requested scopes, and a redirect URI specifying where to send the user back afterward. The user then authenticates directly with the authorization server, entering their actual password only on that provider’s own login page, never on the requesting application’s page at all, which is exactly the property that keeps the user’s real credentials out of the requesting application’s hands entirely.

Once the user approves the requested access, the authorization server redirects back to the application’s specified redirect URI, appending a short-lived authorization code as a query parameter. This code is deliberately not the actual access token yet; it’s a temporary, single-use value that the application’s backend then exchanges for a real access token through a separate, direct, server-to-server request to the authorization server, a request that includes the application’s client secret, proving the request genuinely comes from the legitimate application and not an attacker who somehow intercepted the authorization code in transit through the browser.

This two-step handoff, an intermediate code exchanged server-side for the actual token, rather than handing the access token directly to the browser, is the specific design choice that keeps the real, powerful access token out of the browser’s URL bar, browser history, and any client-side JavaScript that could potentially be compromised through an XSS vulnerability. The authorization code itself is far less dangerous if intercepted, since it’s short-lived, single-use, and useless without the client secret needed to actually redeem it.

Why the Implicit Flow Is Now Deprecated

An older flow, the implicit flow, skipped the authorization code step entirely and returned the access token directly in the browser’s URL fragment after the user approved access. This was originally designed for pure client-side applications, single-page apps with no backend server capable of keeping a client secret confidential, since the authorization code flow’s final exchange step requires exactly that secret.

The implicit flow’s core weakness is structural: putting the access token directly into a URL, even in a fragment that isn’t normally sent to the server in subsequent requests, means that token can end up logged in browser history, referrer headers under some circumstances, or exposed to any browser extension or script with access to the page. Once the industry recognized this as a genuine, exploitable weakness rather than an acceptable trade-off, the current OAuth2 security guidance explicitly recommends against the implicit flow for new applications, replaced by the authorization code flow combined with PKCE, which achieves the same goal, supporting public clients without a confidential secret, without ever putting the actual access token directly into a browser URL at any point.

PKCE: Bringing the Authorization Code Flow to Public Clients

PKCE, pronounced “pixy” and standing for Proof Key for Code Exchange, extends the authorization code flow to work safely for public clients, single-page applications, and mobile apps, that can’t keep a client secret confidential the way a traditional server-side backend can. Without PKCE, a public client using the plain authorization code flow would have no way to prove, during the final code-for-token exchange, that it’s genuinely the same application that initiated the original authorization request, since it has no secret to include in that final request the way a confidential server-side client does.

PKCE solves this by having the client generate a random secret value, called a code verifier, at the very start of the flow, and sending a hashed version of it, the code challenge, along with the initial authorization request. The authorization server stores this code challenge. When the client later exchanges the authorization code for an actual token, it includes the original, unhashed code verifier, and the authorization server checks that hashing this verifier produces the same code challenge submitted earlier. An attacker who intercepts the authorization code in transit, without also having captured the original code verifier, which never travels in a form that’s easily interceptable the way the code itself does, can’t complete the exchange, since they can’t produce a matching verifier for the challenge already on file.

What’s changed more recently in industry guidance is the recommendation to use PKCE even for confidential, server-side clients that could technically rely on a client secret alone. Current OAuth2 security best practice recommends PKCE universally, across public and confidential clients alike, since it adds a meaningful extra layer of protection against authorization code interception attacks at essentially no real implementation cost, and maintaining one consistent flow across every client type, rather than a client-secret-only path for some applications and a PKCE path for others, simplifies both the implementation and the security review considerably.

Client Credentials Flow: When There’s No User at All

The client credentials flow is built for exactly the scenario that confused the developer in this piece’s introduction: machine-to-machine communication with no human user involved anywhere in the process. A backend service authenticating directly to another backend service, a scheduled job pulling data from a third-party API on its own, a microservice calling another microservice, none of these have a user to redirect to a login page, and the client credentials flow reflects that directly by skipping the entire redirect-and-approve dance altogether.

The flow itself is considerably simpler than the authorization code flow: the requesting application sends its client ID and client secret directly to the authorization server’s token endpoint in a single request, and receives an access token back immediately, representing the application’s own identity and permissions rather than any particular user’s. Because this flow inherently requires a client secret transmitted directly, it’s only appropriate for confidential, server-side clients that can actually keep that secret safe; it should never be used from a public client like a mobile app or a single-page application, where the secret would be exposed to anyone who inspected the client’s code or network traffic.

Refresh Tokens: Staying Logged In Without Re-Authenticating Constantly

Access tokens are deliberately short-lived, often expiring within an hour or less, specifically to limit the damage window if one is ever leaked or intercepted. Refresh tokens exist to avoid forcing a user through the full authorization flow again every time an access token expires; instead, the application presents its refresh token directly to the authorization server’s token endpoint and receives a fresh access token in return, without the user needing to do anything at all.

Refresh token rotation is a security practice worth understanding specifically because it closes a real gap in naive refresh token handling. Rather than reusing the same refresh token indefinitely across many token refresh requests, a rotating scheme issues a brand new refresh token alongside every new access token, immediately invalidating the previous one. If a refresh token is ever used a second time after having already been rotated, that’s a strong signal it was stolen and used by an attacker in parallel with the legitimate client, and a well-implemented authorization server can respond to this specific signal by revoking the entire token family immediately, cutting off the attacker’s access rather than allowing it to persist silently and indefinitely.

Refresh tokens generally carry a much longer lifetime than access tokens, sometimes weeks or months, which makes their storage and handling considerably more security-sensitive than a short-lived access token, since a leaked refresh token grants an attacker a much longer window of unauthorized access than a leaked access token alone would.

Scopes: Limiting What a Token Can Actually Do

Scopes let a client request, and a user approve, a specific, limited slice of access rather than blanket, unrestricted permission to an entire account. A calendar application requesting only calendar.read access, rather than full account access, means that even if its access token were somehow compromised, the actual damage an attacker could do with it is bounded to reading calendar data specifically, not touching email, contacts, or any other part of the user’s account entirely unrelated to what the application actually needed.

Requesting only the minimum scopes an application genuinely needs, rather than defensively requesting broad access “just in case a future feature needs it,” is good practice for exactly this reason: every additional scope granted is additional blast radius if the resulting token is ever compromised, and it’s also, from the user’s perspective, a meaningful factor in whether they trust an application enough to approve its access request in the first place. An application requesting full account access for a feature that clearly only needs to read a user’s profile picture reads as a red flag to a reasonably attentive user reviewing the permission request.

OAuth2 vs OpenID Connect

OpenID Connect, commonly abbreviated OIDC, is an identity layer built directly on top of OAuth2, adding exactly what OAuth2 alone doesn’t provide: a verifiable, signed identity token, an ID token, confirming who the user actually is, alongside the access token OAuth2 already provides for authorization purposes. This ID token is a JWT, and it’s worth being able to actually inspect one directly when implementing or debugging an OIDC-based login flow; a JWT decoder makes checking exactly what claims, issuer, and expiry an ID token actually contains considerably easier than trying to manually decode its base64 segments by hand.

The practical distinction worth internalizing: OAuth2 alone tells an application “here’s a token that can access this user’s data within these scopes,” while OpenID Connect additionally tells the application “and here’s cryptographic proof of exactly who this user is.” A login flow that only implements plain OAuth2, using the mere presence of a valid access token as a stand-in for confirming identity, is missing this verifiable identity guarantee entirely, and it’s a common, subtle mistake for developers who reach for OAuth2 specifically to build a login feature without realizing OIDC is the actual protocol built for that specific purpose.

The State Parameter and CSRF Protection

The state parameter is an opaque, application-generated value included in the initial authorization request and expected back, unchanged, in the redirect response from the authorization server. Its purpose is closing off a cross-site request forgery risk specific to OAuth flows: without it, an attacker could potentially trick a victim’s browser into completing an authorization flow the attacker initiated, effectively linking the attacker’s own third-party account to the victim’s session on the requesting application.

A correctly implemented client generates a unique, unpredictable state value before initiating the authorization redirect, stores it temporarily tied to the user’s current session, and verifies on the callback that the returned state matches exactly what was originally sent, rejecting the flow entirely if it doesn’t. Skipping this check, or worse, using a predictable or shared state value across requests, reopens exactly the CSRF vulnerability this parameter exists to close, even though the flow will otherwise appear to work correctly in normal, non-adversarial testing.

Where Tokens Should Actually Be Stored

Access and refresh tokens stored in browser local storage are readable by any JavaScript running on the page, including malicious code injected through an XSS vulnerability elsewhere in the application, which makes local storage a genuinely risky place to keep them despite how commonly it’s used in tutorials for its sheer convenience. An httpOnly cookie, which JavaScript cannot read at all, is a meaningfully safer storage location for anything the browser needs to hold onto, since it removes an entire category of token theft through client-side script injection.

For single-page applications specifically, where storing tokens as httpOnly cookies requires the backend and frontend to be more tightly coordinated than a fully decoupled architecture might otherwise prefer, the trade-off between convenience and security here is worth taking seriously rather than defaulting to local storage purely because it’s the path of least resistance in most getting-started tutorials.

The Backend-for-Frontend Pattern: Keeping Tokens Out of the Browser

For a single-page application, the token storage question has a cleaner answer than choosing between local storage and in-memory variables: keep the tokens out of the browser altogether. The backend-for-frontend pattern, usually shortened to BFF, puts a small server-side component in front of the single-page app. That server performs the entire authorization code flow itself, acting as a confidential client that can genuinely hold a client secret, stores the resulting access and refresh tokens on the server, and gives the browser nothing but an ordinary httpOnly, Secure, SameSite session cookie. When the front end needs to call an API, it calls the BFF, and the BFF attaches the access token and forwards the request.

This shifts the security posture meaningfully. An XSS vulnerability in the front end can still make requests through the BFF while the attacker’s script is running on the page, which is a real limitation worth being honest about, but it can no longer exfiltrate a long-lived refresh token and carry it off to use from the attacker’s own machine days later. The token simply isn’t there to steal. The IETF’s best current practice guidance for browser-based OAuth applications, written by Aaron Parecki, David Waite, and Philippe De Ryck, describes three architecture patterns, a backend for frontend, a token-mediating backend, and a purely browser-based client, and lists the BFF first as the preferred approach for applications handling sensitive data.

It also happens to simplify several other things. There’s no CORS configuration needed against the provider’s token endpoint from the browser, no PKCE verifier sitting in browser storage between redirects, and one consistent place to implement refresh, revocation, and logout. If the front end still needs to make cross-origin calls to your own API, the CORS preflight simulator on this site shows exactly which headers that API needs to return.

The cost is an extra moving part to build, host, and keep available, and it couples the front end more tightly to a specific backend than a fully static single-page app deployed to a CDN would be. For a project that already has a backend, which describes most real products, that cost is small. For a purely static site with no server at all, the authorization code flow with PKCE run directly in the browser remains the accepted fallback, with tokens held in memory only and refresh handled carefully.

Why the Redirect URI Must Match Exactly

Authorization servers require the redirect URI included in an authorization request to match, often character for character, one of the redirect URIs the application registered in advance with that provider. This strictness is a deliberate security control, not unnecessary rigidity: without exact matching, an attacker could potentially register a similar-looking application, or manipulate a loosely-matched redirect URI, to redirect an authorization code intended for a legitimate application to an attacker-controlled endpoint instead, capturing that code and potentially exchanging it for a valid token.

This is why adding a new environment, a staging server alongside production, a new local development port, requires explicitly registering that exact new redirect URI with the provider ahead of time, rather than assuming a similar, previously registered URI will be accepted. A surprising number of “why does my OAuth flow fail with an invalid redirect URI error” support questions trace back to exactly this: a URI that’s close to what’s registered, differing by a trailing slash, an http versus https scheme, or a slightly different port, none of which count as a match under a strict, exact-comparison policy.

Resource Owner Password Credentials: Why It’s Deprecated

An older, simpler-looking flow lets an application collect a user’s actual username and password directly and exchange them for a token in a single request, with no redirect to the authorization server at all. This defeats OAuth2’s entire founding purpose: the whole point of the protocol is keeping a user’s actual credentials out of any application except the identity provider itself, and this flow reintroduces exactly the problem OAuth2 exists to solve, handing raw credentials to a third-party application that then has to be trusted not to misuse or mishandle them.

Current OAuth2 security guidance explicitly recommends against this flow for any new application, and OAuth 2.1 removes it from the specification entirely. It survives today mainly in legacy systems built before this guidance solidified, or in narrow, tightly controlled first-party scenarios where the “third-party” application is actually operated by the same organization as the identity provider itself. Encountering this flow in an existing codebase is generally a signal worth treating as technical debt to migrate away from, not a pattern to extend or copy into new integrations.

Device Authorization Flow: For TVs, CLIs, and Limited-Input Devices

Smart TVs, streaming devices, and command-line tools share a specific constraint: no convenient way to type a username and password, or in some cases no web browser available at all on the device itself. The device authorization flow solves this by decoupling where the user actually authenticates from the device requesting access. The limited-input device displays a short, human-readable code and a URL, and the user opens that URL on a separate device, a phone or laptop with a proper keyboard and browser, enters the code, and completes the authorization there instead, while the original limited-input device polls the authorization server in the background until that separate approval completes.

This is exactly the flow behind the familiar “go to this website and enter this code” screen shown when signing into a streaming app on a television, and it’s worth recognizing as a distinct, purpose-built flow rather than an awkward workaround, since it solves a real, specific input constraint that neither the authorization code flow nor client credentials flow was designed to handle at all.

Token Introspection and Revocation

A resource server receiving an access token sometimes needs to verify that token is still genuinely valid, hasn’t been revoked, and check what scopes it actually carries, without necessarily being able to decode this information locally itself, particularly common when tokens are opaque reference strings rather than self-contained JWTs. The token introspection endpoint, a standardized OAuth2 extension, lets a resource server ask the authorization server directly whether a given token is currently valid and what it’s authorized for, which matters specifically for revocation: a JWT-based access token that hasn’t yet expired but has been explicitly revoked can still look valid if only checked locally against its own signature and expiry, but an introspection call against the authorization server will correctly reflect that it’s been revoked.

A corresponding revocation endpoint lets a client explicitly invalidate a token it no longer needs, on logout, for instance, rather than simply discarding it locally and trusting its natural expiration to eventually close the window. Explicitly revoking a token that’s no longer needed is a meaningfully stronger security practice than only relying on expiration, since it closes the access window immediately rather than leaving a technically-still-valid token that simply isn’t being actively used by the legitimate client anymore.

Flow Comparison at a Glance

FlowUser involvedClient typeTypical use case
Authorization Code + PKCEYesConfidential or publicWeb apps, mobile apps, SPAs acting on a user’s behalf
Client CredentialsNoConfidential onlyMachine-to-machine, backend service integrations
Device AuthorizationYes, on a separate deviceLimited-input devicesSmart TVs, CLI tools, streaming devices
Implicit (deprecated)YesPublicLegacy SPAs; replaced by Authorization Code + PKCE
Resource Owner Password (deprecated)Yes, credentials shared directlyHighly trusted first-party onlyLegacy systems; avoid for new development

Access Token Lifetime and Expiry Tuning

Choosing an access token’s actual lifetime is a genuine trade-off, not a setting to leave at whatever default a library ships with. A very short lifetime, a few minutes, minimizes the damage window if a token leaks, but forces more frequent refresh token exchanges, adding real, if usually small, latency and load. A longer lifetime, several hours, reduces that refresh overhead but extends how long a leaked token remains usable by an attacker before it naturally expires. Most production systems land somewhere in the range of fifteen minutes to an hour for access tokens specifically, treating this as a deliberate security and performance balance rather than an arbitrary number copied from a tutorial’s example configuration.

Handling Consent and Re-Authorization

A user who has already approved an application’s access once generally shouldn’t be forced through the full consent screen again on every subsequent login, and most authorization servers support remembering a previous grant, skipping the explicit approval screen on return visits as long as the previously granted scopes cover what’s currently being requested. This changes the moment an application later requests an additional scope beyond what was originally granted, which correctly triggers a fresh consent prompt specifically for that expanded access, giving the user visibility into exactly what new permission is being requested rather than silently expanding access behind the scenes.

Logging Out Properly

Logout is the part of an OAuth or OpenID Connect integration that most tutorials skip entirely, and it’s easy to get subtly wrong. Clearing your own application’s session cookie ends your application’s session with the user, but it doesn’t end the user’s session with the identity provider. The next time that user clicks “sign in,” the provider may recognize its own still-active session and complete the whole flow silently in a fraction of a second, which looks like the user never logged out at all. On a personal laptop that’s often exactly what people want. On a shared computer, it’s a real problem.

A complete logout does three separate things. It destroys the local application session. It revokes the refresh token, and the access token where the provider supports revocation, at the authorization server, rather than just discarding them locally and trusting their natural expiry, since a discarded-but-unrevoked refresh token is still a valid credential to anyone who copied it earlier. And where the application wants to end the provider session too, it sends the user to the provider’s end-session endpoint, the mechanism OpenID Connect standardizes for exactly this purpose, so the user is signed out of the identity provider as well as the application.

Deciding which of these your application actually needs is a product decision as much as a security one. A consumer app using “sign in with Google” generally shouldn’t sign the user out of Google itself just because they left one small application, and most users would be surprised and annoyed if it did. An enterprise application backed by a corporate identity provider is the opposite case, where ending the single sign-on session is often an explicit requirement.

Real Provider Differences Worth Knowing

While the core OAuth2 specification is standardized, individual providers implement real, practical differences worth checking before assuming a pattern that worked with one provider transfers cleanly to another. Some providers issue refresh tokens by default on every authorization; others require an explicit additional scope or parameter, commonly something like requesting offline access, before a refresh token is included in the response at all, a detail that trips up developers who test a flow once, see it working, and don’t realize a refresh token was silently never issued until their access token expires in production and there’s nothing to refresh it with.

Token lifetime defaults, whether introspection and revocation endpoints are actually implemented, and how strictly scope strings need to be formatted all vary across providers in practice, which is exactly why reading a specific provider’s own OAuth documentation directly, rather than assuming a generic OAuth2 tutorial covers every provider-specific detail, is worth doing before shipping an integration with a new provider for the first time.

Debugging a Failed OAuth Flow Systematically

When an OAuth integration fails, working through the flow’s actual steps in order, rather than guessing broadly, narrows the problem down efficiently. Confirm first whether the initial redirect to the authorization server happens at all and with the expected parameters, client ID, scopes, redirect URI, state. If that redirect succeeds and the user reaches the provider’s own login and consent screen, the next check is whether the callback actually receives an authorization code, or instead receives an error parameter describing what the provider rejected, most commonly an invalid or unregistered redirect URI, or a scope the application isn’t authorized to request.

If the code is received successfully but the subsequent code-for-token exchange fails, the most common causes are a PKCE code verifier that doesn’t match the originally submitted code challenge, an authorization code that’s already been used once and is therefore no longer valid since these codes are strictly single-use, or a client secret that’s incorrect or has been rotated on the provider’s side without the application’s configuration being updated to match. Isolating exactly which step in the sequence is actually failing, rather than treating the whole flow as one opaque black box, is almost always faster than broad, undirected debugging.

Implementing OAuth2 Server-Side in Common Frameworks

Rolling out a hand-built OAuth2 client implementation from scratch is rarely the right call given how many subtle security details the protocol involves, state validation, PKCE verifier generation and storage, exact redirect URI matching, and mature, well-audited libraries exist specifically to handle this correctly. Passport.js remains a common choice for Node.js applications, with a wide ecosystem of provider-specific strategies covering most major identity providers out of the box. Laravel Socialite provides the equivalent for PHP and Laravel applications, wrapping the OAuth2 flow details behind a considerably simpler, higher-level API. Using one of these established libraries rather than implementing the raw protocol steps by hand removes an entire category of the subtle mistakes covered throughout this piece, state parameter handling, PKCE implementation, exact redirect URI enforcement, that a library maintained by many contributors and tested against real providers has already had the chance to catch and fix.

Testing OAuth Integrations Without Hitting Real Provider Rate Limits

Repeatedly testing against a live third-party authorization server during development can hit that provider’s own rate limits or, in some cases, trigger fraud or abuse detection systems not designed to expect rapid, repeated test traffic from a developer’s machine. Most major providers offer a sandbox or test mode specifically for development, and using it deliberately rather than testing exclusively against production credentials avoids both the rate-limit friction and the risk of accidentally leaving test data or test-created accounts inside a genuinely production environment.

Security Checklist Before Shipping an OAuth Integration

Before an OAuth2 integration goes to production, a short, deliberate review catches the mistakes that functional testing never will, because every one of them leaves the flow working perfectly right up until someone actually attacks it. Run through these specifically, not as a general “did we secure it” question:

  • A fresh, unpredictable state value is generated for every authorization request and verified on the callback, and the flow is rejected if it doesn’t match.
  • PKCE is used for every authorization code flow, for confidential and public clients alike.
  • Redirect URIs are registered exactly, use https in production, and contain no wildcards.
  • The client secret lives in server-side secret storage, never in a repository, a front-end bundle, or a mobile app binary.
  • Requested scopes are the minimum the feature genuinely needs.
  • Refresh token rotation is enabled wherever the provider supports it.
  • If OpenID Connect is used for login, the ID token’s signature, issuer, audience, and expiry are all validated, not just decoded.
  • Access tokens and refresh tokens are kept out of application logs.

That last item deserves a specific mention because it’s so common and so easy to miss. Request logging middleware frequently captures full request URLs and headers, which means an access token sent in an Authorization header, or an authorization code appearing in a callback URL, can end up sitting in plain text in log files, error tracking dashboards, and analytics tools that have far wider access than the authorization server ever intended. Scrubbing sensitive headers and query parameters from logs is a five-minute configuration change that closes off a leak most teams never realize they have.

The same discipline applies on the API side of the integration. A resource server accepting access tokens needs to validate them properly, checking signature, issuer, audience, expiry, and scope on every request, and the same principles covered in this site’s REST API design guide around consistent error responses and status codes apply when a token is missing, expired, or lacks the required scope.

Common Mistakes

Using the implicit flow for a new application built today, often because an older tutorial or an outdated library default still references it, adopts a flow current guidance explicitly recommends against, when the authorization code flow with PKCE achieves the same practical goal, supporting a client without a confidential secret, without the specific weaknesses the implicit flow carries.

Reaching for the client credentials flow, or conversely the authorization code flow, without actually identifying whether a human user is genuinely involved in the specific integration being built is a common source of confusion, exactly the scenario opening this piece. The presence or absence of an actual user redirecting through a login screen is the single clearest signal for which of these two flows actually fits.

Treating a valid OAuth2 access token alone as proof of user identity, without implementing OpenID Connect’s ID token verification, is a subtle but real security gap; an access token proves permission to act within some scope, not necessarily which specific, verified user that permission belongs to, and a login system built on this assumption alone can have real, exploitable identity confusion under the wrong conditions.

Skipping or mishandling the state parameter, whether omitting it entirely or reusing a predictable value across requests, reopens a specific, well-documented CSRF vulnerability that will not surface in normal functional testing, only in an actual, deliberate attack, which is exactly why it’s easy to skip without any visible consequence until it’s specifically exploited.

Storing tokens in browser local storage purely because it’s the simplest option shown in a getting-started guide, without weighing the real XSS exposure this creates, trades a meaningful security property for a small amount of implementation convenience, particularly risky for any application handling data more sensitive than a low-stakes demo.

And assuming a redirect URI that’s merely similar to what’s registered will be accepted, rather than confirming an exact match for every environment an application runs in, is a frequent, purely mechanical source of integration failures that has nothing to do with the underlying security logic at all, just a strict string comparison a new environment’s URI didn’t satisfy.

Real Scenarios

Building a “Sign in with Google” feature for a web application

OpenID Connect, built on the authorization code flow with PKCE, is the correct combination here, giving both a verifiable identity token and a properly secured authorization exchange, rather than plain OAuth2 alone, which wasn’t built to answer the “who is this user” question directly.

A backend service that needs to call a third-party API with no user involved

Client credentials flow, since there’s no user to authenticate or redirect, and this flow is specifically built for exactly this machine-to-machine scenario, using the application’s own credentials rather than any individual user’s.

A single-page application or mobile app integrating with a third-party API on behalf of a logged-in user

Authorization code flow with PKCE, since neither a browser-based single-page app nor a distributed mobile app binary can safely keep a traditional client secret confidential, and PKCE is specifically the mechanism that makes the authorization code flow safe without one.

A student project practicing OAuth integration for the first time

Authorization code flow with PKCE is worth learning as the default starting point regardless of the specific project’s architecture, since it’s the current, broadly recommended pattern across both confidential and public clients, and learning it directly avoids picking up outdated habits from an older implicit-flow tutorial.

FAQ

Is OAuth2 the same as OpenID Connect?

No. OAuth2 handles delegated authorization, granting limited access to a resource, while OpenID Connect is an identity layer built on top of OAuth2 specifically to provide a verifiable identity token, which OAuth2 alone doesn’t include.

Should I use the implicit flow for a simple single-page app?

No. Current guidance recommends the authorization code flow with PKCE instead, which achieves the same support for a client without a confidential secret without putting the actual access token directly into the browser’s URL.

Why does PKCE matter even for a server-side app that already has a client secret?

Current OAuth2 security best practice recommends PKCE universally across client types, since it adds meaningful additional protection against authorization code interception at minimal implementation cost, and using one consistent flow across every client type simplifies both implementation and security review.

What happens if a refresh token is stolen?

With refresh token rotation implemented correctly, using a stolen refresh token a second time after it’s already been rotated is a detectable signal of compromise, and a well-implemented authorization server can revoke the entire associated token family in response, limiting the attacker’s window of access.

Why request limited scopes instead of full account access?

Limiting requested scopes bounds the damage if a token is ever compromised, restricting an attacker to only the specific access actually granted rather than an entire account, and it also makes an application’s permission request more trustworthy and understandable to the user reviewing it.

What’s the difference between an access token and an ID token?

An access token is meant for the API being called: it represents permission to do something within a set of scopes, and the client generally shouldn’t try to interpret it. An ID token is meant for the client application itself: it’s a signed JWT that says who the user is and how and when they authenticated, and it should never be sent to an API as if it were an access token.

How long should an authorization code stay valid?

As briefly as practical. The OAuth2 specification recommends a maximum lifetime of ten minutes, and single use, and most providers issue codes that expire in well under a minute since the client is expected to exchange the code immediately after the redirect.

Do I need OAuth2 if I’m just building login for my own website?

Not necessarily. If your application is the only thing that ever needs to trust the login, a conventional server-side session with a properly hashed password is simpler and perfectly sound. OAuth2 and OpenID Connect earn their complexity when you’re letting users sign in with an existing identity provider, or when other applications need delegated access to your API on a user’s behalf.

Is it safe to keep tokens in memory instead of local storage?

It’s meaningfully better, since a token held only in a JavaScript variable disappears on page reload and isn’t sitting in persistent storage for a malicious script to read later. It doesn’t make the token unreachable to injected script running on the same page, though, which is why the backend-for-frontend pattern, keeping tokens out of the browser entirely, is the stronger option when a server component is available.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top