OAuth Explained: From OAuth 1.0 to OAuth 2.0 and Beyond

Every time you click "Sign in with Google" or let a scheduling app read your calendar, you're using OAuth. It's the invisible plumbing behind millions of "Connect your account" buttons — and one of the most misunderstood protocols on the web. In this post we'll trace OAuth from its 2007 origins at Twitter through OAuth 1.0, 1.0a, 2.0, and the upcoming OAuth 2.1, explaining what each version does, what went wrong along the way, and how to use it securely today.

What Is OAuth?

OAuth (short for Open Authorization) is a protocol for delegated authorization: it lets an application access your data on another service without you handing over your password.

The classic analogy is a valet key. A valet key starts your car and drives it a short distance, but it won't open the trunk or the glove box. OAuth gives applications a valet key to your account: enough access to do their job, and nothing more.

One thing to get straight from the start: OAuth is an authorization protocol, not an authentication protocol. It answers "what is this app allowed to do?", not "who is this user?". The "Sign in with..." buttons you see everywhere are actually built on OpenID Connect (OIDC), an identity layer on top of OAuth 2.0 — we'll cover OIDC in detail in a companion post.

  • Authentication = proving who you are (identity)
  • Authorization = deciding what you can access (permissions)

The Problem OAuth Solves: The Password Anti-Pattern

Before OAuth, if you wanted a third-party app to access your data — say, a service that printed your Flickr photos — you gave the app your Flickr username and password. This was called the password anti-pattern, and it was terrible for several reasons:

  1. Total access. The app could do anything your account could do — read private messages, change your password, delete your data.
  2. No revocation. The only way to cut an app off was to change your password, which broke every other app you'd connected.
  3. Credential sprawl. Your password sat in dozens of third-party databases, each one a breach waiting to happen.
  4. No accountability. The service couldn't tell the difference between you and the apps acting as you.

OAuth fixed this by introducing tokens: limited, revocable, scoped credentials issued to each application individually.

A Brief History

  • November 2006: Blaine Cook, working on Twitter's OpenID implementation (an earlier identity protocol — related in name only to today's OpenID Connect), starts discussing API access delegation with Chris Messina, David Recordon, Larry Halff and others. There was no open standard for it.
  • April 2007: A Google Group forms to draft a spec.
  • December 4, 2007: OAuth Core 1.0 is published (the final draft had been released on October 3, 2007).
  • April 2009: A session fixation attack is discovered in OAuth 1.0 (more on this below).
  • June 24, 2009: OAuth Core 1.0 Revision A (OAuth 1.0a) fixes the vulnerability.
  • April 2010: OAuth 1.0a is documented by the IETF (Internet Engineering Task Force, the body that standardizes internet protocols) as RFC 5849. RFCs — "Requests for Comments" — are the internet's official specification documents.
  • October 2012: OAuth 2.0 is published as RFC 6749 (the framework) and RFC 6750 (bearer token usage). It is a complete rewrite, not backward compatible.
  • September 2015: PKCE (Proof Key for Code Exchange, RFC 7636 — explained below) closes a hole in mobile app flows.
  • August 2019: The Device Authorization Grant (RFC 8628) brings OAuth to TVs and command-line tools (CLIs).
  • January 2025: RFC 9700 (OAuth 2.0 Security Best Current Practice) formalizes years of hard-won security lessons.
  • Today: OAuth 2.1 is an IETF draft (draft-ietf-oauth-v2-1, revision 15 as of March 2026) that consolidates OAuth 2.0 plus its security patches into one clean spec.

OAuth 1.0 and 1.0a: Signatures Everywhere

OAuth 1.0 was designed in a world where HTTPS was not universal. It therefore couldn't assume the network was safe, so every single API request had to be cryptographically signed.

The Three-Legged Flow

OAuth 1.0 involves three parties (hence "three-legged"): the user, the consumer (the third-party app), and the service provider. The flow:

  1. The consumer obtains a request token from the provider.
  2. The user is sent to the provider to approve the request token.
  3. The consumer exchanges the approved request token for an access token.
  4. The consumer signs every API call with the access token and a shared secret.

The Signature (and Why Developers Hated It)

Each request required building a signature base string — the HTTP method, the URL, and every parameter, each percent-encoded, sorted alphabetically, and concatenated — then computing an HMAC-SHA1 signature over it. (HMAC is a keyed hash: a way to prove a message came from someone holding a secret key. Don't worry about its internals here — the point is both sides must compute it identically, byte for byte.)

Here's a real example. Suppose an app makes this API call:

GET https://api.example.com/photos?file=vacation.jpg&size=original

with consumer key dpf43f3p2l4k3l03, access token nnch734d00sl2jdk, nonce kllo9940pd9333jh (a nonce — short for "number used once" — is a random value that prevents an attacker from replaying a captured request), and timestamp 1751980000. The signature base string becomes — note how every character gets percent-encoded, so :// turns into %3A%2F%2F:

GET&https%3A%2F%2Fapi.example.com%2Fphotos&file%3Dvacation.jpg%26oauth_consumer_key%3Ddpf43f3p2l4k3l03%26oauth_nonce%3Dkllo9940pd9333jh%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1751980000%26oauth_token%3Dnnch734d00sl2jdk%26oauth_version%3D1.0%26size%3Doriginal

Signing that with the consumer secret kd94hf93k423kf44 and token secret pfkkdhi9sl3r4s00 (joined by &) via HMAC-SHA1 yields:

fBfTR7MvqF1Yjj+5pJ2vO19cR+0=

In Python, the whole procedure looks like this:

import hmac, hashlib, base64
from urllib.parse import quote

method = "GET"
url = "https://api.example.com/photos"
params = {
    "oauth_consumer_key": "dpf43f3p2l4k3l03",
    "oauth_token": "nnch734d00sl2jdk",
    "oauth_signature_method": "HMAC-SHA1",
    "oauth_timestamp": "1751980000",
    "oauth_nonce": "kllo9940pd9333jh",
    "oauth_version": "1.0",
    "size": "original",
    "file": "vacation.jpg",
}

def enc(s):
    return quote(s, safe="~")  # RFC 5849 percent-encoding

# 1. Sort and encode all parameters
norm = "&".join(f"{enc(k)}={enc(v)}" for k, v in sorted(params.items()))

# 2. Build the signature base string
base_string = "&".join([method, enc(url), enc(norm)])

# 3. Sign with HMAC-SHA1 (key = consumer_secret & token_secret)
signing_key = enc("kd94hf93k423kf44") + "&" + enc("pfkkdhi9sl3r4s00")
signature = base64.b64encode(
    hmac.new(signing_key.encode(), base_string.encode(), hashlib.sha1).digest()
).decode()

print(signature)  # fBfTR7MvqF1Yjj+5pJ2vO19cR+0=

Get one byte wrong — a space encoded as + instead of %20, parameters sorted differently, a stray port number in the URL — and the server rejects the request with an unhelpful 401. Entire libraries, debugging tools, and forum threads existed just to fight signature mismatches. This pain is a big part of why OAuth 2.0 exists.

The 1.0a Fix: Session Fixation

In April 2009, a session fixation attack was found in OAuth 1.0. An attacker could start an OAuth flow with a legitimate consumer, capture the request token, and trick a victim into authorizing the attacker's token (for example, by sending them the authorization link). When the victim approved it, the attacker could complete the exchange and access the victim's account.

OAuth 1.0a fixed this by adding an oauth_verifier — a code the provider gives the user upon approval, which the consumer must present when exchanging the request token — and by having the consumer declare its callback URL up front, when requesting the request token. Since the attacker never receives the victim's verifier, the stolen flow dies. OAuth 1.0a is the version that became RFC 5849, and it's what people mean today when they say "OAuth 1.0."

OAuth 2.0: A Complete Rewrite

OAuth 2.0 (RFC 6749, October 2012) threw away signatures and rebuilt the protocol around two ideas:

  1. TLS everywhere. By 2012, HTTPS was cheap and widespread. Instead of signing every request, OAuth 2.0 requires TLS (Transport Layer Security — the encryption behind HTTPS) and sends plain bearer tokens.
  2. Multiple flows for multiple client types. Web apps, mobile apps, single-page apps (SPAs — web apps that run entirely in the browser), and server-to-server services have different constraints, so OAuth 2.0 defines several grant types.

OAuth 2.0 also renamed the parties: what OAuth 1.0 called the consumer is now the client. And it introduced a distinction that matters throughout the rest of this post: apps that can keep a secret (server-side code) are confidential clients; apps that can't (mobile, desktop, SPAs — anything a user could decompile or inspect) are public clients.

A bearer token (RFC 6750) works like cash: whoever holds it can use it. No signature, no proof of identity — just:

GET /api/photos HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

That simplicity is why developers embraced OAuth 2.0, and also why protecting tokens became the central security concern. The tradeoff was controversial at the time — lead editor Eran Hammer famously resigned from the working group in 2012 over the direction of the spec — but OAuth 2.0 went on to become the dominant authorization protocol on the internet.

The Four Roles

Role Who it is Example
Resource owner The user who owns the data You
Client The app that wants access A photo-printing app
Authorization server Issues tokens after the user approves accounts.google.com
Resource server The API holding the data photos.googleapis.com

Tokens and Scopes

  • Access token — short-lived credential the client presents to the API (minutes to hours).
  • Refresh token — longer-lived credential used to obtain new access tokens without re-asking the user.
  • Scope — a string limiting what the token can do, e.g. photos.read. This is the valet-key part: a token scoped to photos.read can't delete photos or read email.

Grant Types (Flows)

Grant type Use case Status today
Authorization code Web apps with a backend ✅ Recommended
Authorization code + PKCE Mobile apps, SPAs, and now all clients ✅ Recommended
Client credentials Server-to-server (no user involved) ✅ Recommended
Device authorization (RFC 8628) TVs, consoles, CLIs ✅ Recommended
Refresh token Renewing access tokens ✅ Recommended (with rotation)
Implicit Legacy SPAs ❌ Deprecated (RFC 9700)
Resource owner password Legacy trusted apps ❌ Deprecated (RFC 9700)

The implicit grant returned access tokens directly in the browser URL fragment (the part of a URL after #), where they leaked through browser history, Referer headers, and injected scripts. The password grant had apps collect the user's actual password — the very anti-pattern OAuth was invented to kill. RFC 9700 says clients MUST NOT use the password grant and SHOULD NOT use the implicit grant, and OAuth 2.1 removes both entirely. If you're building something new, you never need them.

Worked Example: Authorization Code Flow with PKCE

PKCE (Proof Key for Code Exchange, RFC 7636 — pronounced "pixy") was created because mobile apps can't keep secrets: anything embedded in an app binary can be extracted. Without PKCE, malware on a phone could intercept the authorization code on its way back to the legitimate app and exchange it for tokens. PKCE fixes this by making the client prove, at exchange time, that it is the same client that started the flow.

Here's the complete flow, using the official test vector from RFC 7636 so you can verify every value.

Step 1 — Client generates a secret and its fingerprint.

The client creates a random code_verifier, then computes the code_challenge as the SHA-256 hash of it, encoded in Base64URL (a way of writing binary data using only URL-safe characters):

code_verifier  = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
code_challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

(These are the exact values from RFC 7636, Appendix B — you can recompute them with the code below.)

Step 2 — Client sends the user to the authorization server with the challenge (the hash), never the verifier (request wrapped across lines for readability):

GET /authorize?response_type=code
    &client_id=example-client
    &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
    &scope=photos.read
    &state=af0ifjsldkj
    &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
    &code_challenge_method=S256 HTTP/1.1
Host: auth.example.com

The state parameter is a random value the client checks when the user returns — it blocks cross-site request forgery (CSRF), where an attacker tricks your browser into completing a flow the attacker started.

Step 3 — The user logs in and approves. The authorization server stores the challenge and redirects back:

HTTP/1.1 302 Found
Location: https://app.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj

Step 4 — Client exchanges the code, revealing the verifier:

POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&client_id=example-client
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

The server hashes the verifier and compares it to the challenge from Step 2. A thief who stole the code in transit doesn't have the verifier (it never left the client), so the stolen code is useless.

Step 5 — Server returns tokens:

{
  "access_token": "2YotnFZFEjr1zCsicMWpAA",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
  "scope": "photos.read"
}

Generating PKCE Values in Code

Python:

import secrets, hashlib, base64

verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
challenge = base64.urlsafe_b64encode(
    hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()

print("code_verifier: ", verifier)
print("code_challenge:", challenge)
# Example output (yours will differ — the verifier is random):
# code_verifier:  _nuybGt2F3w78jD3FkkBMc9eBxtoSszAWO6Q82ApldQ
# code_challenge: bdK-iK3eNsC3nw6Q7y0bA8jlI6fVuExsGn_aH13f4CI

Node.js:

const crypto = require('node:crypto');

const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');

console.log('code_verifier: ', verifier);
console.log('code_challenge:', challenge);
// Example output (yours will differ — the verifier is random):
// code_verifier:  x_m98YUBvbcdJ0HCvuTSxxijraNDHLVkX5yV3XFZ3gs
// code_challenge: MpgIkKOZ4qJU_rixtuxQXTwF3R77l7_vdVa1Eq2gbGU

Security Analysis

OAuth 2.0's security history is well documented across RFC 6819 (the 2013 threat model) and RFC 9700 (the 2025 Best Current Practice, or BCP). The recurring attack classes:

Token leakage. Bearer tokens in URLs end up in server logs, browser history, and Referer headers. This killed the implicit grant. Mitigation: never put tokens in URLs; use the authorization code flow and send tokens only in headers or POST bodies.

Authorization code interception. On mobile platforms, a malicious app can register the same custom URL scheme (the myapp:// links that open a specific app) as a legitimate app and receive its authorization code. Mitigation: PKCE (now required for all clients in OAuth 2.1).

CSRF on the redirect. An attacker starts a flow with their own account and tricks the victim's browser into finishing it — logging the victim into the attacker's account or attaching the victim's resources to it. Mitigation: the state parameter (or PKCE, which also covers this case).

Redirect URI manipulation. If the authorization server matches redirect URIs loosely (wildcards, prefix matching), attackers can get codes delivered to URLs they control — often chained with open redirects on the client's domain. Mitigation: exact string matching of pre-registered redirect URIs (required in OAuth 2.1).

Mix-up attacks. A client that talks to multiple authorization servers can be tricked into sending credentials from an honest server to a malicious one. Mitigation: RFC 9700 recommends the iss (issuer) parameter in authorization responses so the client can verify who is answering.

Refresh token theft. A long-lived refresh token is a valuable target. Mitigation: RFC 9700 and OAuth 2.1 require refresh tokens for public clients to be either sender-constrained (bound to the client so a thief can't use them) or rotated (each use issues a new one and invalidates the old; reuse of an old token signals theft and revokes the whole chain).

OAuth 2.1: Cleaning House

OAuth 2.1 (still an IETF draft at the time of writing) adds nothing fundamentally new. It merges RFC 6749, RFC 6750, PKCE, the native-app and browser-app guidance, and the security BCP into a single document, with the deprecated parts cut out. The headline changes:

  1. PKCE is required for every client using the authorization code flow — even confidential server-side clients (with one narrow exception for OpenID Connect clients that use the nonce mechanism instead).
  2. Implicit and password grants: removed (already deprecated by RFC 9700, as covered above).
  3. Exact redirect URI matching is required — no wildcards or prefix matching.
  4. Bearer tokens may not appear in URL query strings.
  5. Refresh tokens for public clients must be sender-constrained or single-use.

If you follow RFC 9700 today, you are already writing OAuth 2.1. That's the point: the spec codifies existing best practice rather than inventing anything.

Which Flow Should I Use?

You are building... Use
Server-side web app Authorization code + PKCE
Single-page app (SPA) Authorization code + PKCE
Mobile / desktop app Authorization code + PKCE
Backend service or cron job (no user) Client credentials
Smart TV / console / CLI on another device Device authorization grant
Anything with the implicit or password grant Stop — migrate to the above

Quick Reference

Version Year Spec Key trait
OAuth 1.0 2007 OAuth Core 1.0 HMAC-SHA1 signatures on every request
OAuth 1.0a 2009 RFC 5849 (2010) Adds oauth_verifier, fixes session fixation
OAuth 2.0 2012 RFC 6749, 6750 Bearer tokens over TLS, multiple grant types
+ PKCE 2015 RFC 7636 Protects public clients' code exchange
+ Device grant 2019 RFC 8628 Input-constrained devices
+ Security BCP 2025 RFC 9700 Deprecates implicit & password grants
OAuth 2.1 draft draft-ietf-oauth-v2-1 Consolidation; PKCE mandatory

What's Next: OpenID Connect

OAuth deliberately says nothing about who the user is — an access token is an opaque permission slip (the app can't, and shouldn't, read anything into it), not an identity document. Trying to use plain OAuth for login leads to real vulnerabilities (an app that accepts any valid access token as "proof" of identity can be fooled by a token issued to a different app). OpenID Connect solves this properly with ID tokens, standard identity claims, and discovery. That's the subject of the next post in this series.

References