OAuth #

Before OAuth existed, the most common way to let third-party apps access user data was to hand over the username and password directly. Users had to trust that the app would store their credentials safely, not use them for anything else, and never leak them. That’s too much trust to place in an unknown party. OAuth came to solve this fundamental problem: how to allow limited access to user resources without ever sharing the password. Understanding OAuth properly — not just “how to use Google Login” — is the foundation for building secure, scalable, auditable authorization systems. This article covers OAuth from the problems it solves, the four main grant types with complete diagrams, PKCE, the OAuth 1.0 vs 2.0 differences, its relationship with OpenID Connect, and the most common anti-patterns.

The Problem OAuth Solves #

Imagine a user wanting to use a third-party calendar management app that needs access to their Google Calendar. Before OAuth, the only way was:

// The old way (before OAuth):
1. The user gives their Google username and password to the calendar app
2. The app stores those credentials for repeated access
3. The app logs in as the user to access the calendar

Problems:
✗ The user can't grant access to only the calendar — access to all Google services
✗ No way to revoke access without changing the Google password
✗ If the calendar app leaks, the Google password leaks too
✗ No way to prove who actually performed an action

// With OAuth:
1. The calendar app redirects to Google OAuth
2. The user logs in directly at Google (credentials never reach the app)
3. The user approves: "Allow this app to access your Google Calendar"
4. Google gives a limited access token to the app
5. The app uses the token for calendar access — can't access Gmail, Drive, etc.

Advantages:
✓ Credentials never shared with third-party apps
✓ Limited (scoped) access to only the calendar
✓ Users can revoke access anytime from Google settings
✓ Tokens can expire automatically

The Four Roles in OAuth #

OAuth 2.0 defines four roles to understand before seeing how the flows work.

flowchart LR
    RO["Resource Owner\n(User)\nOwner of the data\nbeing accessed"]
    C["Client\n(App)\nThe app wanting\nto access user data"]
    AS["Authorization Server\n(Auth Server)\nIssues tokens\nafter user consent"]
    RS["Resource Server\n(API)\nStores the data\nand validates tokens"]

    RO -->|"Consent"| AS
    C -->|"Request authorization"| AS
    AS -->|"Access token"| C
    C -->|"Request + token"| RS
    RS -->|"Data"| C

    style RO fill:#27AE60,color:#fff,stroke:#1E8449
    style C fill:#2980B9,color:#fff,stroke:#1A5276
    style AS fill:#E67E22,color:#fff,stroke:#D35400
    style RS fill:#8E44AD,color:#fff,stroke:#6C3483

Resource Owner is the user — the owner of the data the third-party app wants to access. They give consent.

Client is the app wanting to access data. It can be a web app, mobile app, or server-side application. The client must be registered at the Authorization Server and have a client_id.

Authorization Server is the server handling user authentication, showing the consent screen, and issuing tokens. It can be a service like Google, GitHub, or an internal auth server.

Resource Server is the API storing the data being accessed. It validates the access token on every request. The Authorization Server and Resource Server can be on the same or different infrastructure.


OAuth 2.0 Grant Types #

OAuth 2.0 defines several grant types — different ways to obtain an access token depending on the application context. Choosing the wrong grant type is a common source of security issues.

flowchart TD
    Start["What type of app?"]

    Q1{"Is there\na user present?"}
    Q2{"Client type?"}
    Q3{"Need user\nidentity info?"}

    AuthCode["Authorization Code + PKCE\n→ Most secure\n→ For web & mobile with users"]
    ClientCred["Client Credentials\n→ For machine-to-machine\n→ No user"]
    OIDC["Authorization Code + PKCE\n+ OpenID Connect\n→ For login and user identity"]
    Device["Device Authorization\n→ For smart TVs, CLIs\n→ No browser"]

    Start --> Q1
    Q1 -->|"User present"| Q2
    Q1 -->|"No user\n(service-to-service)"| ClientCred
    Q2 -->|"Web app or\nmobile app"| Q3
    Q2 -->|"Device without\na browser"| Device
    Q3 -->|"Authorization only"| AuthCode
    Q3 -->|"Need identity\n(login)"| OIDC

    style AuthCode fill:#27AE60,color:#fff
    style ClientCred fill:#2980B9,color:#fff
    style OIDC fill:#8E44AD,color:#fff
    style Device fill:#E67E22,color:#fff

Authorization Code Flow + PKCE #

This is the most common and most recommended flow for applications involving users — web apps, mobile apps, and SPAs alike. PKCE (Proof Key for Code Exchange) is a mandatory extension for applications that can’t store client_secret securely (SPAs, mobile).

sequenceDiagram
    participant U as User / Browser
    participant C as Client App
    participant AS as Authorization Server
    participant RS as Resource Server

    Note over C: Generate code_verifier (random string)\ncode_challenge = SHA256(code_verifier)

    C->>U: Redirect to the Authorization Server
    Note over U: GET /authorize?<br/>response_type=code<br/>&client_id=app123<br/>&redirect_uri=https://app.com/callback<br/>&scope=read:profile read:calendar<br/>&state=random-csrf-token<br/>&code_challenge=abc123<br/>&code_challenge_method=S256

    U->>AS: Opens the login and consent page
    AS->>U: Shows: "This app wants access to your profile and calendar"
    U->>AS: User logs in and clicks "Allow"

    AS->>U: Redirect to the callback URL
    Note over U: GET /callback?code=AUTH_CODE&state=random-csrf-token

    U->>C: Authorization code received
    Note over C: Verify state === the stored csrf token

    C->>AS: POST /token<br/>{ code, client_id, redirect_uri,<br/>  code_verifier }
    Note over AS: Verify code_verifier against code_challenge

    AS-->>C: { access_token, refresh_token, expires_in }

    C->>RS: GET /api/profile<br/>Authorization: Bearer ***
    RS-->>C: User profile data

Why PKCE matters: without PKCE, an authorization code intercepted at the redirect URI could be exchanged directly for an access token. With PKCE, the interceptor can’t complete the exchange because they don’t have the code_verifier that only exists in the original client.

Important parameters in Authorization Code + PKCE:

code_verifier:  a random 43-128 character string (created by the client, temporarily stored)
code_challenge: BASE64URL(SHA256(code_verifier)) — sent to the auth server at the start
                When exchanging the code → token, the client sends the real code_verifier
                The auth server verifies: SHA256(code_verifier) === the stored code_challenge
                → Only the client that made the initial request can complete the flow

state:          a random string to prevent CSRF
                The client stores state → sends it to the auth server → verifies on callback
                If state mismatches → reject the request (possible CSRF attack)

scope:          only request what's truly needed
                "read:profile read:calendar" — not "all" or "*"

Client Credentials Flow #

For machine-to-machine communication — no user involved. Used when one service needs to access another service’s API on its own behalf, not on behalf of a user.

sequenceDiagram
    participant S as Service A (Client)
    participant AS as Authorization Server
    participant RS as Resource Server (Service B)

    Note over S,AS: No user — the service authenticates itself

    S->>AS: POST /token<br/>{ grant_type: client_credentials,<br/>  client_id, client_secret,<br/>  scope: "read:orders" }

    AS->>AS: Verify client credentials

    AS-->>S: { access_token, expires_in }

    S->>RS: GET /api/orders<br/>Authorization: Bearer ***
    RS-->>S: Orders data
When to use Client Credentials:
  ✓ Service A needs to pull data from Service B automatically
  ✓ Background jobs needing API access
  ✓ CI/CD pipelines needing deploys or notifications
  ✓ Microservice-to-microservice where no user is present

Don't use Client Credentials:
  ✗ When a user is present — use Authorization Code
  ✗ When acting on behalf of a specific user

Device Authorization Flow #

For devices without browsers or with limited input — smart TVs, CLI tools, printers.

Device Authorization Flow:

1. The device requests a device_code from the Authorization Server
   POST /device_authorization { client_id, scope }
   Response: { device_code, user_code: "ABCD-1234", verification_uri: "example.com/activate" }

2. The device shows instructions to the user:
   "Open example.com/activate in your browser and enter the code: ABCD-1234"

3. The device starts polling the Authorization Server
   POST /token { grant_type: device_code, device_code }

4. The user opens the browser (on a phone or another computer), enters the code, logs in, and consents

5. The device's polling gets a response:
   { access_token, refresh_token, expires_in }

OAuth 1.0 vs OAuth 2.0 — A Difference in Philosophy #

Many people think OAuth 2.0 is “the more secure version” of OAuth 1.0. That’s a misconception — they’re different philosophies.

OAuth 1.0 — Security at the request level:
  Every request is cryptographically signed
  Consumer secret + token secret are used to create the signature
  Secure EVEN WITHOUT HTTPS (theoretically)
  Very complex to implement correctly
  Almost nobody uses it for new use cases today

OAuth 2.0 — Security at the transport level:
  HTTPS is the only security layer
  Requests aren't signed — the token alone is proof of authorization
  Far simpler and more flexible
  The current industry standard
  But: its security heavily depends on correct implementation
Why OAuth 1.0 is almost unused anymore:
  ✗ Very complex implementation (many signature details that can go wrong)
  ✗ Harder debugging
  ✗ No support for modern flows (mobile, SPA, device)
  ✗ Minimal library ecosystem remaining

Situations where OAuth 1.0 is still found:
  → Twitter API v1 (before migration to OAuth 2.0)
  → Some legacy enterprise integrations
  → If you integrate with old systems that still require it

OAuth vs OpenID Connect — A Frequently Confused Difference #

This is one of the most common confusions:

OAuth 2.0:
  Answers: "Is this app allowed to access resource X?"
  About: AUTHORIZATION
  Output: an access token for API access
  Doesn't answer: "Who is this user?"

OpenID Connect (OIDC):
  Built on top of OAuth 2.0
  Adds: an ID token (JWT) containing user identity information
  Answers: "Who is this user and is this app allowed to log in as them?"
  About: AUTHENTICATION + authorization
  Output: an access token + an ID token

When to use which:
  Only need API access (read data, write data): → OAuth 2.0
  Need user login and to know who they are: → OpenID Connect (OAuth 2.0 + OIDC)
flowchart LR
    subgraph OAuth["OAuth 2.0 only"]
        OC["Client App"]
        OAS["Auth Server"]
        ORS["Resource API"]
        OC -->|"Request access"| OAS
        OAS -->|"access_token"| OC
        OC -->|"access_token"| ORS
        ORS -->|"Data"| OC
    end

    subgraph OIDC["OAuth 2.0 + OpenID Connect"]
        IC["Client App"]
        IAS["Auth Server\n(also an Identity Provider)"]
        IRS["Resource API"]
        IC -->|"Request access\n+ scope: openid"| IAS
        IAS -->|"access_token\n+ id_token (JWT)\n+ userinfo endpoint"| IC
        IC -->|"access_token"| IRS
        IRS -->|"Data"| IC
    end

Scopes — The Least Privilege Principle #

Scopes define the access boundaries granted to a client. This is the implementation of the least privilege principle in OAuth.

// ANTI-PATTERN: Overly broad scopes
scope = "all" or scope = "*"
→ A calendar app has access to email, documents, payment, every service
→ If the app is compromised, the user's entire account is exposed

// CORRECT: Request only the scopes truly needed
scope = "read:calendar write:calendar"
→ The app can only read and write the calendar
→ Can't access email, documents, or other services

// Examples of well-designed scopes:
Read profile: "profile:read" or "openid profile"
Read email: "email:read" or "email"
Calendar access: "calendar:read calendar:write"
Read orders: "orders:read"
All orders: "orders:*"  ← maybe too broad, consider more granular

// How to request scopes in the consent prompt:

"This app is requesting permission to:
 ✓ See your profile name and photo
 ✓ See and edit your calendar events
 ✗ Access your email  (not requested)"
→ Users know exactly what they're granting

Secure Token Management #

Access Tokens #

Recommended characteristics:
  Format: JWT (self-contained) or opaque (random string)
  Duration: 5-60 minutes (depending on sensitivity)
  Storage (web): memory — not localStorage
  Storage (mobile): secure storage

Validation at the Resource Server:
  If JWT: verify signature + exp + iss + aud
  If opaque: introspect at the Authorization Server (POST /introspect)

Refresh Tokens #

Characteristics:
  Duration: longer (days to weeks)
  Web storage: HttpOnly Secure SameSite cookies
  Mobile storage: Keychain (iOS) / Keystore (Android)

Rotation: mandatory to implement (see the JWT article for details)
Revocation: can be revoked at logout or on suspicious activity

OAuth Anti-Patterns to Avoid #

Using the Implicit Flow #

// ✗ DEPRECATED: Implicit Flow (don't use it)
GET /authorize?response_type=token&client_id=...
→ The access token is returned directly in the URL fragment
→ Tokens can leak via the Referer header, browser history, or server logs
→ No refresh token support

// ✓ CORRECT: Authorization Code + PKCE for SPAs and mobile
GET /authorize?response_type=code&code_challenge=...
→ An authorization code is returned, exchanged server-side
→ More secure, supports refresh tokens

Not Validating the State Parameter #

// ✗ Anti-pattern: state not validated
function handleCallback(code, state) {
    // Directly exchange the code for a token without checking state
    exchangeCode(code)
}
// Vulnerable to CSRF — attackers can trick users into authorizing the attacker's request

// ✓ CORRECT: Always validate state
function handleCallback(code, state) {
    const savedState = sessionStorage.getItem('oauth_state')
    if (state !== savedState) {
        throw new Error('State mismatch — possible CSRF attack')
    }
    exchangeCode(code)
}

Storing client_secret in the Frontend #

// ✗ DANGEROUS: client_secret in frontend JavaScript
const response = await fetch('/token', {
    body: JSON.stringify({
        client_id: 'my-app',
        client_secret: 'super-secret-123',  // EXPOSED in the source code
        code: authCode
    })
})
// Anyone inspecting the source code can steal the client_secret

// ✓ CORRECT: Token exchange happens server-side
// The frontend sends the auth code to the backend
// The backend (server) holds the client_secret and performs the token exchange
// The frontend never touches the client_secret

// For SPAs and mobile apps that can't have a client_secret:
// Use PKCE — designed for public clients without client_secret

Requesting Excessive Scopes #

// ✗ Requesting every permission possibly needed someday
scope = "read:profile write:profile read:contacts write:contacts
         read:calendar write:calendar read:email send:email
         read:documents write:documents payment:read payment:write"
→ Users get suspicious and may refuse consent
→ The security surface area is very wide if token theft occurs

// ✓ Request scopes incrementally based on real needs
At initial login: scope = "read:profile"
When the user opens the calendar feature: scope = "read:calendar write:calendar"
When the user opens the contacts feature: scope = "read:contacts"
→ More focused user consent
→ Minimal scope = minimal damage if a token leaks

Using OAuth as Authentication Alone #

// ✗ A common misconception:

"We've implemented OAuth, so users are authenticated"
OAuth only answers: "Is this app allowed to access the resource?"
Not: "Who is this user?"

Problem: an OAuth access token doesn't directly prove user identity
         in a standardized way

// ✓ Use OpenID Connect for authentication:
scope = "openid profile email"
→ The response contains an ID token (JWT) with user identity information
→ The ID token is verified for authentication
→ The access token is used for authorization (API access)
OAuth 2.0 isn’t an authentication protocol — it’s an authorization framework. Many “login with OAuth” implementations actually use OpenID Connect (OIDC) built on top of OAuth 2.0. If you want to know “who is this user”, you need the ID token from OIDC, not just the access token from OAuth.

Building Your Own Authorization Server vs Using a Provider #

Building your own Authorization Server is a very complex, high-risk task if done carelessly. Consider these trade-offs seriously.

Use an existing provider (Auth0, Keycloak, Okta, Supabase Auth) if:
  ✓ No very specific customization needs
  ✓ The team lacks security and OAuth implementation expertise
  ✓ Time-to-market is a priority
  ✓ You want to leverage mature features (MFA, social login, audit logs)

Consider building your own if:
  ✓ Regulatory requirements demand on-premise data
  ✓ Customization needs providers can't meet
  ✓ The team has sufficient expertise and maintenance bandwidth
  ✓ Scale requires full control

If building your own, recommended libraries:
  Go:     golang.org/x/oauth2, ory/fosite
  Node.js: node-oidc-provider
  Java:    Spring Security OAuth, Keycloak (self-hosted)
  Python:  authlib

Secure OAuth Implementation Checklist #

FLOW SELECTION:
  □ Using Authorization Code + PKCE (not Implicit Flow)
  □ Client Credentials only for machine-to-machine without users
  □ Device Authorization for browserless devices

SECURITY PARAMETERS:
  □ state parameter used and validated (CSRF protection)
  □ code_verifier and code_challenge implemented (PKCE)
  □ redirect_uri registered at the Authorization Server and strictly validated
  □ client_secret absent from frontend code (server only)

SCOPES:
  □ The minimal scopes sufficient for the feature
  □ Scopes requested incrementally when possible
  □ Consent screen explains scopes in user-understandable language
  □ No wildcard scopes or "all access"

TOKEN MANAGEMENT:
  □ Access tokens short-lived (≤60 minutes)
  □ Refresh tokens stored in HttpOnly Secure cookies (web) or secure storage (mobile)
  □ Token rotation implemented for refresh tokens
  □ Logout endpoint revokes refresh tokens

AUTHENTICATION VS AUTHORIZATION:
  □ OpenID Connect used when user identity is needed (scope: openid)
  □ ID tokens and access tokens used for their distinct purposes
  □ ID tokens not used as access tokens (and vice versa)

RESOURCE SERVER:
  □ Every request validates the access token
  □ Token scopes validated against the accessed endpoint
  □ Token expiry validated

MONITORING:
  □ Login events logged (successful and failed)
  □ Token issuance logged
  □ Suspicious activity (many failed attempts) alerted

Summary #

  • OAuth is an authorization framework, not authentication — it answers “is this app allowed to access resource X”, not “who is this user”. For authentication and user identity, use OpenID Connect built on top of OAuth 2.0.
  • Authorization Code + PKCE is the default choice for all apps with users — web apps, SPAs, and mobile alike. PKCE replaces the need for client_secret in public clients and protects against authorization code interception.
  • Implicit Flow is deprecated — don’t use it. Authorization Code + PKCE is more secure and supports refresh tokens.
  • Client Credentials for machine-to-machine — when no user is present, services authenticate themselves using client_id and client_secret.
  • The state parameter is mandatory for CSRF protection — create a random string, store it in the session, send it in the authorization request, verify it on callback. Without it, CSRF attacks can force users to authorize an attacker’s request.
  • Scopes are the implementation of least privilege — request only the scopes truly needed right now. Excessive scopes widen the damage if a token leaks and make users hesitant to give consent.
  • client_secret must never be in the frontend — for SPAs and mobile apps that can’t store secrets securely, use PKCE. Token exchange always happens server-side.
  • OAuth 1.0 doesn’t need to be chosen for new use cases — its complexity is very high and it offers no real benefits in the era of ubiquitous HTTPS. Use OAuth 2.0.
  • OAuth 2.0’s security depends on correct implementation — unlike OAuth 1.0’s request signing, OAuth 2.0 relies heavily on HTTPS, parameter validation, and secure storage.
  • Consider using a mature auth provider — building your own Authorization Server is very complex and risky. Auth0, Keycloak, or Supabase Auth already handle many security edge cases easily missed when building from scratch.
#

← Previous: JWT   Next: GraphQL Federation

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact