How OAuth Works: Delegating Access Without Sharing Secrets

September 03, 2026 · 33 min read

Part of Under the Hood — deep dives into the technology we use every day.

Before OAuth, if an app wanted to access your data on another service, it asked for your password.

Not a special token. Not a scoped permission. Your actual password. The one you used to log in. You’d type it into some third-party app (maybe a Twitter client, maybe a photo-printing service that needed your Flickr library) and that app would use your credentials to log in as you, pretending to be you, doing whatever it needed to do.

This worked, in the same way that giving a stranger a copy of your house keys “works” when they need to water your plants. Technically functional. Horrifying in retrospect.

The app had full access to your account. It could read everything, change everything, delete everything. There was no way to grant limited access. No way to revoke access to one app without changing your password and breaking every other app you’d shared it with. And if that third-party app got breached? The attacker didn’t just get your data from that app; they got your actual credentials. Your keys to the whole house.

Something better was needed. And it wasn’t a niche problem. By the mid-2000s, the web was becoming interconnected. Services wanted to talk to each other. Your photo-sharing site wanted to find your friends on your email provider. Your blog wanted to cross-post to your social network. Your fitness tracker wanted to log workouts to your health dashboard. Every one of these integrations required your password. Every password you shared was a liability.

The companies knew it was bad, too. Google, Yahoo, and others built proprietary solutions (AuthSub, BBAuth) but each one was different, each one was locked to a single provider, and none of them solved the general problem. What the web needed was an open standard. A protocol that any service could implement, that worked the same way everywhere, and that let users delegate access without handing over the keys.

The path to that standard started with a question about identity.

A quick note on terminology

Before we dive in, let’s be precise about two words that are often confused.

Authentication is proving who you are. You show your ID at a bar. You type your password into a login form. You press your thumb against a fingerprint reader. The system verifies your identity.

Authorisation is proving what you’re allowed to do. Your ID might prove you’re over 18, but it doesn’t mean you can walk behind the bar. Your key card might open your office door but not the server room. A permission has been granted, or it hasn’t.

OAuth is primarily about authorisation: “this app is allowed to read your calendar.” OpenID Connect, which builds on top of OAuth, adds authentication: “this person is Craig.” The protocol names are confusing because people use them interchangeably, but the distinction matters. When you see “OAuth” in this post, think “permission.” When you see “OpenID Connect” or “OIDC,” think “identity.”

OpenID: proving who you are

In the mid-2000s, the web was drowning in login forms. Every site wanted you to create an account. Every account needed a username and password. Brad Fitzpatrick, the engineer behind LiveJournal at Danga Interactive, was frustrated by this. He’d built a blogging platform with millions of users who already had identities, so why should they have to create new ones everywhere they went?

In 2005, Fitzpatrick and others launched OpenID. The idea was simple: you have an identity provider (say, LiveJournal or your own website), and when another site needs to know who you are, it redirects you to your provider. You authenticate there, with your provider rather than the requesting site, and your provider vouches for you. The requesting site never sees your password. It just gets confirmation: “Yes, this person is who they say they are.”

OpenID answered one question: who are you?

But it didn’t answer another: what can you access?

Knowing that you’re Craig doesn’t tell a recipe app whether it’s allowed to read your Google Calendar. Identity and authorisation are different problems. OpenID solved the first. The second was still wide open.

OAuth: delegating access

In 2006 and 2007, engineers at Twitter ran into exactly this problem. Third-party Twitter clients needed to post tweets and read timelines on behalf of users, and the only way to do it was to collect users’ passwords. Blaine Cook, Twitter’s lead developer, and Chris Messina, an open-web advocate, started working on a protocol that could delegate access without sharing credentials.

The result was OAuth 1.0, published in 2007. The core insight was this: instead of giving an app your password, you go to the service yourself, authenticate directly, and grant the app a token, a limited credential that represents your permission. The token might let the app read your tweets but not delete them. It might expire after an hour. And you can revoke it at any time without changing your password.

OpenID was “who are you?” OAuth was “what can you access?”

OAuth 1.0 worked, but it was complex. Every API request had to be cryptographically signed using a specific process: you took the HTTP method, the URL, and all the parameters, sorted them, concatenated them into a “signature base string,” then signed it with HMAC-SHA1 using a combination of the consumer secret and the token secret. Get any step wrong (a parameter out of order, an encoding inconsistency, a trailing slash in the URL) and the signature failed. Debugging was painful. Libraries were inconsistent. Developers spent more time wrestling with signatures than building features.

The controversy over what came next split the community. In 2012, the IETF published OAuth 2.0 (RFC 6749), a complete rewrite that dropped the per-request signing in favour of bearer tokens over HTTPS. The lead editor, Eran Hammer, resigned from the working group and called the result “the road to hell,” arguing that it was too broad, too flexible, and that leaving security decisions to implementers was a recipe for disaster. He had a point: OAuth 2.0 is a framework, not a protocol, and its flexibility means that two “OAuth 2.0” implementations can be completely incompatible.

But the simplicity won. Developers adopted OAuth 2.0 overwhelmingly. The “just use HTTPS” approach to transport security was pragmatic and effective. And the flexibility that Hammer criticised also meant that OAuth 2.0 could adapt to use cases (mobile apps, single-page apps, machine-to-machine communication) that OAuth 1.0 was never designed for.

It’s the version that powers the web today. Almost every “Sign in with…” button, every API integration, every mobile app that accesses a cloud service uses OAuth 2.0 at its core. It’s one of the most widely deployed protocols on the internet, and understanding how it works, and where it fails, is worth the investment.

The redirect dance

The most common OAuth 2.0 flow is the Authorization Code Grant. It’s what happens when you click “Sign in with Google” on a website, and it has a specific choreography that’s designed so the app never touches your credentials.

Here’s how it works, step by step.

  1. You click “Sign in with Google.” The app (let’s call it RecipeApp) constructs a URL that points to Google’s authorisation server. This URL includes RecipeApp’s client ID (a public identifier that Google issued when the developer registered the app), a redirect URI (where Google should send you back after you’ve authenticated), the scopes the app is requesting (more on this shortly), and a random state parameter (for security, we’ll come back to this).

  2. Your browser redirects to Google. You’re now on Google’s login page. Google’s page, Google’s domain, Google’s TLS certificate. RecipeApp is nowhere in the picture. It has no way to see what happens here.

  3. You authenticate with Google. You type your Google password. Maybe you complete a multi-factor authentication challenge. This all happens between you and Google. RecipeApp is waiting.

  4. Google asks for your consent. “RecipeApp wants to view your calendar events and read your email contacts. Allow?” Google shows you exactly what the app is asking for. You can accept or refuse.

  5. Google redirects back to RecipeApp with an authorisation code. If you accept, Google redirects your browser back to RecipeApp’s redirect URI, with a short-lived authorisation code in the URL query string. Something like https://recipeapp.com/callback?code=abc123&state=xyz789.

  6. RecipeApp exchanges the code for tokens. This is the crucial step. RecipeApp’s server (not your browser) makes a direct HTTPS request to Google’s token endpoint, sending the authorisation code plus RecipeApp’s client secret, a private credential that only RecipeApp’s server knows. Google verifies everything: the code is valid, it hasn’t been used before, the client secret matches, the redirect URI matches. If everything checks out, Google responds with an access token (and possibly a refresh token and an ID token).

  7. RecipeApp uses the access token to call Google’s API. When RecipeApp wants to read your calendar, it includes the access token in the HTTP request header: Authorization: Bearer eyJhbGciOi.... Google’s API checks the token, confirms it’s valid and has the right scopes, and returns the data.

That’s the dance. Two redirects through your browser, one server-to-server exchange, and the app gets a scoped, time-limited credential without ever seeing your password.

Why the dance matters

Every step in this flow exists for a reason.

The app never sees your password. You authenticate directly with Google. The app gets a token, not your credentials. If the app gets breached, the attacker gets tokens, which are scoped and revocable, not your Google password.

The authorisation code is short-lived and single-use. It typically expires in minutes. Even if someone intercepts it (by snooping on the redirect URL), it’s useless without the client secret, which was never sent through the browser.

The code-for-token exchange happens server-to-server. The client secret never travels through the browser, never appears in a URL, never shows up in browser history or server logs. It stays on RecipeApp’s server, sent directly to Google’s server over HTTPS.

The state parameter prevents cross-site request forgery. Without it, an attacker could craft a malicious redirect that tricks your browser into completing the OAuth flow with the attacker’s authorisation code, linking the attacker’s account to yours. The state parameter is a random value that RecipeApp generates before the flow starts and verifies when it comes back. If it doesn’t match, something’s wrong, and the flow is aborted. RFC 6749 makes this strongly recommended.

The separation of concerns here is worth appreciating. The browser handles the redirects. The user handles the authentication. The authorisation server handles the consent and code issuance. The app’s server handles the code-for-token exchange. No single component sees the whole picture. It’s a chain of handoffs, each one designed so that no party has to trust another with more information than necessary.

Scopes: granular permissions

When RecipeApp redirects you to Google, the URL includes a scope parameter. Something like:

scope=https://www.googleapis.com/auth/calendar.readonly
      https://www.googleapis.com/auth/contacts.readonly

Scopes are the mechanism for limiting what the access token can do. Instead of granting full access to your Google account, the token only permits what the scopes allow. Read your calendar, yes. Delete your emails, no. Change your password, absolutely not.

This is the principle of least privilege, built into the protocol. A well-designed app requests only the scopes it needs. Google (or whatever provider) shows you those scopes on the consent screen so you can make an informed decision.

In practice, not every app is well-designed. Some request far more access than they need (“this weather app wants to read your email”) and users click “Allow” without reading the screen. Research has consistently shown that most users don’t read consent screens carefully. A 2012 study from the University of California, Berkeley found that users were more likely to approve permissions they didn’t understand than to deny them, because denial meant the app wouldn’t work.

Google has responded to this by tightening its OAuth consent screen policies. Apps that request sensitive scopes now go through a manual review process. The consent screen shows specific, human-readable descriptions of what each scope allows. And since 2019, Google requires apps to demonstrate a “limited use” policy explaining why they need each scope they’re requesting.

The protocol gives you the tools for granularity. The ecosystem is slowly learning to enforce them. But the tension between user convenience and informed consent remains a human problem, not a protocol one.

Tokens: the currency of OAuth

OAuth 2.0 uses several types of tokens, each with a different role.

Access tokens are the workhorses. They’re what the app sends to the API to access your data. They’re short-lived (typically minutes to hours) and they’re bearer tokens, meaning anyone who holds the token can use it (which is why they must only travel over HTTPS and be stored securely).

Access tokens can be opaque strings (random characters that only the issuing server can validate) or they can be self-contained JSON Web Tokens (JWTs) that include claims about the user, the scopes, and the expiry time, signed by the authorisation server. JWTs let the API validate the token without calling back to the authorisation server every time, which helps with performance at scale.

Refresh tokens solve the expiry problem. When an access token expires, the app doesn’t need to send you back through the whole redirect dance (imagine being asked to re-authenticate every hour). Instead, RecipeApp’s server sends the refresh token to Google’s token endpoint and gets a fresh access token. The user never knows this happened. From their perspective, the app just keeps working.

Refresh tokens are long-lived (days, weeks, sometimes indefinitely) and they’re stored securely on the server. They’re more sensitive than access tokens because they can generate new ones. A stolen access token gives an attacker temporary access (until it expires). A stolen refresh token gives an attacker the ability to generate new access tokens indefinitely. This is why refresh tokens should only be stored on the server side, never in the browser, and why refresh token rotation (discussed later) is important.

ID tokens aren’t part of OAuth 2.0 proper; they come from OpenID Connect, which we’ll get to shortly. An ID token is a JWT that contains claims about the user’s identity: their subject identifier, name, email, when they last authenticated. It’s the identity layer on top of OAuth’s authorisation layer.

A note on bearer tokens and their risks. “Bearer” means exactly what it sounds like: whoever bears (holds) the token can use it. There’s no cryptographic binding between the token and the client presenting it. If an attacker intercepts an access token (through a man-in-the-middle attack, a compromised log file, or a cross-site scripting vulnerability that reads it from the browser) they can use it just as well as the legitimate app. This is why HTTPS isn’t optional in OAuth 2.0; it’s the transport security that the entire model depends on. Bearer tokens over unencrypted HTTP would be like posting your house key on a public noticeboard: technically a working key, but usable by anyone who happens to see it.

Some newer specifications, like DPoP (Demonstration of Proof-of-Possession), aim to fix this by binding tokens to the client’s cryptographic key pair. With DPoP, even if an attacker steals the token, they can’t use it without also having the private key. It’s a significant improvement in token security, though adoption is still in its early stages.

PKCE: OAuth for public clients

The Authorization Code Grant described above works beautifully when the app has a server-side backend, somewhere secure to keep the client secret. But what about a mobile app? A single-page JavaScript app running entirely in the browser? These are “public clients”: they can’t keep secrets because their code is visible to the user (and to anyone who decompiles the app or inspects the page source).

The early answer was the Implicit Grant, which skipped the code exchange and returned the access token directly in the browser redirect. The authorisation server would redirect back to the app with the token right there in the URL fragment (the part after the #).

This was convenient but problematic. The token appeared in the URL fragment, which could be read by JavaScript running on the page, including malicious scripts injected through cross-site scripting vulnerabilities. The token could be captured by browser extensions. It could leak through referrer headers if the page contained links to external sites. And there was no way to verify that the token was issued to the right app, because there was no client secret and no code exchange to bind the token to a specific client.

The Implicit Grant is now effectively deprecated. If you see it in a codebase, replace it.

The modern answer is PKCE, Proof Key for Code Exchange, pronounced “pixie.” It adds a clever layer of security to the Authorization Code Grant that works even without a client secret.

Here’s how it works:

  1. Before starting the flow, the app generates a random string called a code verifier.
  2. The app computes the SHA-256 hash of the code verifier, Base64URL-encodes it, and sends this code challenge along with the initial authorisation request.
  3. The flow proceeds as normal: the user authenticates, grants consent, and the authorisation server returns an authorisation code.
  4. When the app exchanges the code for tokens, it sends the original code verifier (not the hash).
  5. The authorisation server hashes the code verifier and compares it to the code challenge it received earlier. If they match, the server knows that the entity exchanging the code is the same one that initiated the request.

Why does this work? Because of a property of cryptographic hash functions: they’re easy to compute in one direction and infeasible to reverse. Anyone can hash the code verifier to produce the code challenge. But nobody can take the code challenge and work backwards to produce the code verifier. So an attacker who sees the authorisation request (which contains the code challenge) and intercepts the authorisation code still can’t exchange it for tokens; they don’t have the code verifier. And the code challenge (the hash) that was sent in the initial request is useless for reconstructing the verifier because SHA-256 is a one-way function.

It’s a commitment scheme, in the cryptographic sense. The app commits to a value (the code verifier) by publishing its hash (the code challenge), and later proves it made that commitment by revealing the original value. Simple, elegant, and effective.

PKCE is now recommended for all OAuth clients, not just public ones. It’s a defence-in-depth measure that costs almost nothing to implement.

The name deserves a moment’s appreciation. PKCE was originally proposed by Nat Sakimura, John Bradley, and Naveen Agarwal in RFC 7636, published in 2015. It was designed specifically for native mobile apps where an attacker could register a custom URI scheme to intercept the OAuth redirect, a real attack that was demonstrated on both Android and iOS. The solution was elegant: prove that the entity finishing the flow is the same one that started it, using a commitment scheme (send the hash first, reveal the preimage later) that requires no shared secret and adds minimal complexity to the flow.

If you’re implementing OAuth today, use PKCE. If you’re using a library that handles OAuth, it almost certainly supports PKCE already. And if you encounter an OAuth integration that doesn’t use PKCE and doesn’t have a client secret, walk away.

OpenID Connect: identity on top of authorisation

Remember OpenID, the identity protocol from 2005? It never achieved mass adoption. The user experience was clunky (you had to type a URL as your identifier) and the protocol didn’t integrate well with the rest of the web’s emerging OAuth infrastructure.

OpenID Connect (OIDC), published in 2014, took a different approach. Instead of building a separate identity protocol, it built identity on top of OAuth 2.0. When an app includes the openid scope in its authorisation request, the authorisation server returns an ID token alongside the access token. This ID token is a signed JWT containing standardised claims about the user.

OIDC defines a set of standard claims: sub (a unique subject identifier), name, email, email_verified, picture, locale, and others. It also defines a userinfo endpoint where the app can fetch additional claims using the access token.

This is what powers “Sign in with Google,” “Sign in with Apple,” “Sign in with GitHub.” The app requests the openid scope (and maybe email and profile), gets an ID token back, verifies its signature, and reads the user’s identity from the claims. The app never creates its own account system. It delegates identity to a provider the user already trusts.

The result is the social login landscape we have today. Google, Apple, Microsoft, GitHub, Facebook: they’re all OpenID Connect providers. And the standardisation means any OIDC library can work with any provider. You don’t need a Google-specific SDK to support Google login. You need an OIDC library and Google’s published discovery document.

That discovery document is worth a look. Visit the URL above and you’ll get a JSON file that tells your app everything it needs to know: the authorisation endpoint, the token endpoint, the userinfo endpoint, the supported scopes, the supported signing algorithms, the public keys for verifying ID tokens. OIDC calls this OpenID Connect Discovery, and it means that configuring a new provider can be as simple as pointing your library at the discovery URL and letting it figure out the rest.

This is the kind of boring, unglamorous standardisation work that makes interoperability possible. Nobody gets excited about a JSON metadata document. But it’s the reason you can add “Sign in with Google” to your app in an afternoon instead of a month.

One subtlety worth noting: OIDC’s ID token is designed to be consumed by the app, not by an API. The access token goes to the API. The ID token stays with the app and tells it who the user is. Sending an ID token to an API as a bearer credential is a common mistake that conflates the two purposes. The ID token’s audience claim (aud) is the app’s client ID, not the API’s identifier. An API that accepts ID tokens intended for a different app is vulnerable to a confused deputy attack: the app presents a valid token, but it was issued for a different purpose.

Sign in with Apple: a privacy rethinking

When Apple launched Sign in with Apple in 2019, it followed the OIDC standard but added a twist that reflected Apple’s privacy-first philosophy.

Most providers hand over your real email address when you sign in. Apple gives you a choice: share your real email, or let Apple generate a unique, random relay address (something like dxc3kj7r@privaterelay.appleid.com). Emails sent to that address are forwarded to your real inbox, but the app never learns your actual email. You get a different relay address for each app, so they can’t correlate your accounts across services.

You can also disable forwarding for any individual app at any time, effectively disappearing from that app’s ability to contact you.

This was a meaningful shift. Before Sign in with Apple, social login meant trading convenience for data: the provider gave the app your identity, and the app got your name, email, and sometimes more. Apple made it possible to get the convenience without the data leakage. It pressured other providers to think more carefully about what information they expose by default.

Apple also required any app that offered third-party social login (Google, Facebook, etc.) to also offer Sign in with Apple if the app was distributed through the App Store. This was controversial (developers saw it as Apple leveraging its platform power) but it had the practical effect of making privacy-preserving login available to millions of users who wouldn’t have sought it out.

The social login landscape today is dominated by a handful of providers: Google (by far the most widely supported), Apple (required on iOS, increasingly available elsewhere), Facebook (still common but declining as developers move away from Meta’s ecosystem), Microsoft (dominant in enterprise via Azure AD, now called Entra ID), and GitHub (the de facto choice for developer tools). Each follows the OIDC standard, with minor variations in supported claims and scope names. If you’re building an app, supporting Google and Apple covers the majority of users. Adding Microsoft covers enterprise. Adding GitHub covers developers. That’s four providers and one standard, a manageable integration.

Client Credentials Grant: machines talking to machines

Not every OAuth flow involves a human. When one server needs to access another server’s API (your payment service calling your shipping service, your backend calling a cloud provider’s management API) there’s no user to redirect through a browser.

The Client Credentials Grant handles this. The client authenticates directly with the authorisation server using its client ID and client secret, and receives an access token. No browser. No redirect. No human consent. Just two services establishing that one is authorised to call the other.

This is the OAuth equivalent of a service account. The permissions are configured when the client is registered, not granted by a user at runtime. It’s widely used in microservice architectures and cloud platforms; AWS, Google Cloud, and Azure all use OAuth-based flows for service-to-service authentication.

The Client Credentials Grant is simpler than the Authorization Code Grant because there’s no user to redirect and no consent to obtain. But it’s not without its own security considerations. The client secret must be stored securely (not in source code, not in environment variables that get logged). Many implementations use short-lived JWTs signed with the client’s private key instead of a shared secret, which avoids the need to transmit the secret at all. And the principle of least privilege applies here just as it does for user-facing flows: a service should have only the permissions it needs, not blanket access to everything.

Device Authorization Grant: OAuth on your telly

Here’s a scenario the original OAuth authors didn’t foresee: you buy a new smart TV, open the YouTube app, and need to sign in. Your TV has no keyboard. No browser. Typing your Google password with a remote control, one character at a time, navigating an on-screen alphabet: it’s an experience that makes you question every life choice that led you to this moment.

The Device Authorization Grant solves this. The TV shows you a short URL (like google.com/device) and a code (like WDJB-MJHT). You open the URL on your phone or laptop (a device that does have a proper browser and keyboard) enter the code, authenticate with Google, and grant consent. Meanwhile, the TV is polling the authorisation server in the background, asking “has the user authorised me yet?” Once you complete the flow on your phone, the next poll succeeds, and the TV receives its tokens.

The user code is short and human-readable because it has to be typed by hand. The polling interval is specified by the server to prevent abuse. The code has a limited lifetime (typically 10 to 15 minutes) after which it expires and the TV has to start over. And the whole flow works without the limited device ever needing a full browser or keyboard. You’ll see this pattern on smart TVs, gaming consoles, CLI tools, and IoT devices: anywhere that needs OAuth but doesn’t have a convenient way to handle redirects.

It’s a nice example of how OAuth’s framework nature, its ability to support different “grant types” for different situations, has allowed it to adapt to use cases that the original authors never imagined. A TV authenticating via a phone would have been science fiction in 2007. By 2019, it was a standard with an RFC number.

Token revocation: taking back the keys

One of OAuth’s most important features is one that rarely gets discussed: revocation. You can take back access.

Go to myaccount.google.com/permissions and you’ll see every app that has access to your Google account. Each one has a “Remove Access” button. Click it, and the app’s tokens are invalidated. The next time it tries to call Google’s API, it gets a 401 Unauthorized. No password change required. No impact on other apps. You revoke one app’s access and everything else continues working.

RFC 7009 defines a standard token revocation endpoint. The app (or the user, through the provider’s management interface) sends the token to this endpoint, and the authorisation server invalidates it. For opaque tokens, the server marks them as revoked in its database. For JWTs, it’s trickier: since JWTs are self-contained and validated without calling the server, revocation requires either short expiry times (so revoked tokens stop working quickly) or a revocation list that the API checks.

This is a quiet revolution compared to the pre-OAuth world. When you shared your password with an app, the only way to revoke access was to change your password. That broke every app and every device that used that password. With OAuth, revocation is surgical. It’s the difference between changing the locks on your entire house and simply deactivating one specific key card.

OAuth’s consent model puts the user in control. In theory. In practice, most users have clicked “Allow” on so many consent screens that the process has become muscle memory. This is consent fatigue, the same phenomenon that plagues cookie banners, terms of service agreements, and every other permission prompt the web throws at us. The consent screen becomes a speed bump, not a decision point.

Some providers have tried to address this. Google’s consent screen redesign in 2019 made scopes more prominent and easier to understand. Apple’s approach is more radical: Sign in with Apple gives users a binary choice (share email or use a relay address) rather than a page of granular permissions. The OIDC standard supports a concept called claims requesting, where the app can specify exactly which user attributes it needs and mark each as essential or voluntary, giving the provider a chance to present a more meaningful consent interface.

But the deeper problem is structural. OAuth puts the burden of security decisions on the person least equipped to make them: the end user. A user who wants to use RecipeApp doesn’t want to reason about whether calendar.readonly is an acceptable scope. They want to save their recipes. OAuth’s consent model is a significant improvement over “give me your password,” but it’s not the end of the story.

Common mistakes

OAuth is well-designed, but implementations are only as good as the people building them. Here are the mistakes that show up again and again.

Storing tokens insecurely. Access tokens in local storage (vulnerable to cross-site scripting). Refresh tokens in cookies without the HttpOnly and Secure flags. Tokens logged to plaintext files. The token is a bearer credential; anyone who has it can use it. It deserves the same care as a password.

Over-requesting scopes. An app that asks for read:email and write:repos and admin:org when it only needs to verify your identity. Users who pay attention will decline. Users who don’t will grant far more access than intended. Request the minimum scopes your app needs. You can always ask for more later, incrementally.

Not validating tokens. When your app receives an ID token, you need to verify the signature, check the issuer, check the audience (is this token meant for your app?), check the expiry, and check the nonce if you’re using one. Skipping any of these checks opens the door to token substitution attacks: using a token issued for one app to break into another.

Using the Implicit Grant. It was a reasonable compromise in 2012 when browser capabilities were limited. It’s not reasonable now. The access token appears in the URL fragment, which can leak through browser history, referrer headers, and HTTP logs. Use the Authorization Code Grant with PKCE instead. The OAuth Security Best Current Practice document is explicit about this.

Ignoring the state parameter. Without it, your OAuth flow is vulnerable to CSRF attacks. An attacker can initiate a flow with their own account and trick a victim’s browser into completing it, linking the attacker’s external identity to the victim’s account. Always generate a random state, store it in the user’s session, and verify it when the callback arrives.

Open redirectors. If your app’s redirect URI validation is loose (accepting any URL under your domain, or worse, any URL at all) an attacker can manipulate the OAuth flow to redirect the authorisation code to a server they control. Validate redirect URIs exactly. No wildcards. No subdirectory matching. Exact string comparison, as RFC 6749 recommends.

Not rotating refresh tokens. Refresh tokens are long-lived and powerful: they can generate new access tokens indefinitely. If a refresh token is stolen, the attacker has persistent access until the token is explicitly revoked.

Best practice is refresh token rotation: each time a refresh token is used, the server issues a new one and invalidates the old one. If the old token is used again (because an attacker stole it), the server detects the reuse and revokes the entire token family, every access token and refresh token associated with that grant. This limits the window of compromise and makes stolen refresh tokens detectable. The legitimate app always has the latest refresh token, so if an old one appears, it must have been stolen.

Confusing authentication and authorisation. OAuth is an authorisation protocol. It answers “what can this app access?” not “who is the user?” If you’re using OAuth to log users in without OpenID Connect, you’re probably relying on an access token to call a user-info API and treating whatever comes back as the user’s identity. This works until it doesn’t; for example, if the access token was issued for a different app and the API doesn’t check the audience. Use OpenID Connect and ID tokens for authentication. Use OAuth access tokens for API access. Don’t mix them up.

What happens when it goes wrong

Security researchers regularly find OAuth implementation vulnerabilities in the wild, and the patterns are instructive.

In 2020, researchers from the University of Luxemburg published a comprehensive analysis of OAuth and OIDC implementations across hundreds of websites. They found that a significant number failed to validate the state parameter, making them vulnerable to CSRF attacks. Others accepted tokens from any issuer without checking the audience claim, meaning a token obtained from one app could be used to log into a completely different app.

The Covert Redirect vulnerability, disclosed in 2014, exploited open redirect endpoints on OAuth providers’ own domains. If the authorisation server redirected to any URL the app specified (even a different path on the same domain that happened to redirect elsewhere), an attacker could chain redirects to capture the authorisation code. The fix was straightforward (exact redirect URI matching) but it highlighted how a small implementation shortcut (loose URI matching) could undermine the entire security model.

Then there’s the category of attacks that exploit the gap between OAuth’s authorisation layer and the application’s own logic. An app might correctly obtain an access token with read:profile scope, read the user’s email from the profile API, and use that email as the user’s identity, without checking whether the email has been verified. An attacker who controls an unverified email address on the OAuth provider can then impersonate the real owner of that email. The OAuth Security Best Current Practice document addresses many of these patterns, but it’s a 60-page document that most developers don’t read.

The lesson isn’t that OAuth is broken; it’s that OAuth is a set of building blocks, and assembling them incorrectly produces something that looks like a secure system but isn’t. Libraries and frameworks handle most of the complexity, and using a well-maintained library is the single most effective thing a developer can do to avoid these mistakes.

Where this all sits

OAuth 2.0 is plumbing. Most users never know it exists. They see “Sign in with Google” and click it. They see a consent screen and tap “Allow.” They don’t see the redirect dance, the code exchange, the scoped bearer tokens, the PKCE challenges, the signed JWTs. And that’s the point. Good infrastructure is invisible.

But the design is worth understanding, because the tradeoffs are everywhere. OAuth separates authentication from authorisation, and authorisation from access. It lets you grant limited, revocable, time-bounded access to your data without sharing your credentials. It’s the reason you can connect dozens of apps to your Google account and revoke any one of them without changing your password.

It’s also imperfect. Bearer tokens can be stolen. Consent screens are routinely ignored. Implementations cut corners. The protocol gives you the tools for security, but it can’t force anyone to use them well.

The history matters, too. OAuth didn’t emerge from a committee designing the perfect protocol in a vacuum. It emerged from practical frustration: from engineers who looked at the “just give us your password” pattern and said “we can do better.” From Brad Fitzpatrick’s annoyance at redundant login forms, through Blaine Cook and Chris Messina’s work on delegated access, to the OIDC working group building identity on top of it all. Each step solved a real problem that the previous step left open.

If you’re a developer implementing OAuth, the most important thing you can do is use a well-maintained library for your language and framework. Don’t roll your own. The protocol has too many security-critical details (nonce generation, state validation, token verification, redirect URI matching, PKCE challenge computation) for a from-scratch implementation to be worth the risk. Auth0, Keycloak, and platform-specific SDKs from Google, Apple, and Microsoft all handle the heavy lifting. Your job is to configure them correctly and keep them updated.

And the next steps are already in motion. OAuth 2.1 is consolidating best practices into the core spec: PKCE required by default, Implicit Grant removed, refresh token rotation recommended. The Grant Negotiation and Authorization Protocol (GNAP) is exploring what comes after OAuth, with richer interaction models and finer-grained delegation.

But for now, OAuth 2.0 with OpenID Connect is the backbone. It’s what makes the modern web’s interconnectedness possible: the ability to use one identity across hundreds of services, to grant and revoke access with a click, to connect apps without sharing secrets.

Go to your Google Account settings and look at the “Third-party apps with account access” page. Count the apps. Think about how many of them have access to some slice of your data. Then think about what the alternative would be: each of those apps holding your Google password, with no way to revoke one without revoking all of them, and a single breach exposing everything.

That’s the world OAuth replaced. It’s not perfect, but it’s immeasurably better.

It’s infrastructure worth knowing about. Even if, especially if, it’s designed so you never have to think about it.

Next time you see a “Sign in with…” button, you’ll know what’s happening behind it. Two redirects, a code exchange, a scoped token, and a design philosophy that says: you should never have to share your password with anyone except the service that issued it.

That’s the idea. Simple to state. Surprisingly hard to get right. And, along with HTTPS, DNS, and a handful of other invisible protocols, one of the foundations of trust on the modern web.

Good plumbing. The kind you never think about until it breaks.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.