OAuth 2.0

A delegated authorization protocol, not a login mechanism — the four roles, the one flow you should actually use, and where OpenID Connect adds the identity layer OAuth2 deliberately leaves out

← Back to Index

What is OAuth 2.0 — and Why Does it Exist?

OAuth 2.0 solves one specific problem: letting a third-party application act on a user's behalf against some resource — their Google contacts, their GitHub repositories, their shipping address stored with a carrier — without that application ever seeing the user's actual password for that resource, and with access limited to exactly the scope the user consented to. It is a delegated authorization protocol. It is explicitly not an authentication protocol, and conflating the two is the single most common OAuth2 mistake — "Login with Google" as most developers use it is actually built on OpenID Connect (OIDC), a thin identity layer added on top of OAuth2 specifically because OAuth2 alone never tells the client who the user is, only that the client was granted some access token.

// BEFORE — the "integration" asks the user to hand over their real password
public class ShippingSyncService {
    public void connectCarrierAccount(String carrierUsername, String carrierPassword) {
        // Our e-commerce backend now holds the customer's actual carrier password,
        // in full, indefinitely. It can do ANYTHING that account can do — not
        // just "read shipping addresses" — and a breach of OUR database now
        // compromises the customer's account on a system we don't even own.
        carrierApiClient.login(carrierUsername, carrierPassword);
    }
}

// AFTER — the user authorizes a SCOPED, REVOCABLE token, never sharing a password
public class ShippingSyncService {
    public String buildAuthorizationUrl(String state, String codeChallenge) {
        return UriComponentsBuilder.fromHttpUrl("https://carrier.example.com/oauth2/authorize")
            .queryParam("client_id", "shop-example-com")
            .queryParam("scope", "shipments:read")   // exactly this, nothing more
            .queryParam("response_type", "code")
            .queryParam("state", state)
            .queryParam("code_challenge", codeChallenge)
            .queryParam("code_challenge_method", "S256")
            .toUriString();
        // The customer authenticates directly with the carrier, on the carrier's
        // own login page — our application never sees their credentials at all,
        // only ever receives a token scoped to "read shipments," revocable by
        // the customer at any time from the carrier's own account settings.
    }
}

The Four Roles

RoleWho this isE-commerce example
Resource Owner The user who owns the data and can grant access to it The customer, who owns their carrier shipping account
Client The application requesting access on the resource owner's behalf Our e-commerce backend, wanting to read shipment tracking data
Authorization Server Authenticates the resource owner and issues tokens after consent The carrier's OAuth2 login/consent server
Resource Server Hosts the protected data and accepts the issued access token The carrier's shipment-tracking API

Very often the Authorization Server and Resource Server are operated by the same organization (as in this example) or even the same physical service — the roles are still worth separating conceptually, because it's what makes sense of every flow diagram you'll encounter.

Authorization Code Flow with PKCE — the Only Flow You Should Use for User-Facing Clients

Of the several grant types OAuth2 originally defined, exactly one remains the current recommendation for anything a human logs into — a browser SPA, a mobile app, or a traditional server-rendered web app: Authorization Code with PKCE (Proof Key for Code Exchange, RFC 7636). Current best practice (RFC 9700) recommends PKCE for every client type, not just the traditionally "public" ones without a client secret — it closes an authorization-code-interception window that a client secret alone doesn't fully protect against.

/*
 * 1. Client generates a random "code_verifier", derives a "code_challenge"
 *    from it (SHA-256, base64url), and redirects the user's browser to the
 *    authorization server with that challenge attached.
 *
 * 2. User authenticates directly with the authorization server (never with
 *    the client) and consents to the requested scope.
 *
 * 3. Authorization server redirects back to the client's redirect_uri with a
 *    short-lived, single-use "authorization code."
 *
 * 4. Client exchanges the code for tokens by calling the authorization
 *    server's token endpoint DIRECTLY (server-to-server, not via the
 *    browser) — presenting the ORIGINAL code_verifier alongside the code.
 *
 * 5. Authorization server re-derives the challenge from the presented
 *    verifier and checks it matches what it received in step 1. If an
 *    attacker intercepted the authorization code in step 3 (e.g. via a
 *    misconfigured redirect_uri or a logged referrer), they still can't
 *    redeem it — they never had the original code_verifier, only the code.
 */

@Service
public class PkceService {

    public String generateCodeVerifier() {
        byte[] bytes = new byte[32];
        new SecureRandom().nextBytes(bytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
    }

    public String deriveCodeChallenge(String codeVerifier) throws NoSuchAlgorithmException {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
        return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
    }
}
The deprecated Implicit and Resource Owner Password Credentials grants

The Implicit grant returned the access token directly in the browser's URL fragment, with no code-exchange step at all — it exposed the token to browser history, referrer headers, and any script on the page, and current OAuth2 security guidance deprecates it outright in favor of Authorization Code with PKCE, even for pure single-page applications. The Resource Owner Password Credentials grant has the client collect the user's actual username and password directly and exchange them for a token — which defeats the entire premise of OAuth2 (the client never sees the password) and should only ever appear, if at all, in a first-party migration path being actively phased out.

Client Credentials Flow — When There is No User in the Loop

A nightly batch job that reconciles inventory counts against a supplier's API isn't acting on behalf of any specific customer — it's the e-commerce backend itself that needs authorization. The Client Credentials grant fits exactly this case: the client authenticates directly with its own client ID and secret, no resource owner or browser redirect involved at all.

# application.yml — a client registered purely for machine-to-machine calls
spring:
  security:
    oauth2:
      client:
        registration:
          supplier-api:
            provider: supplier
            client-id: ${SUPPLIER_CLIENT_ID}
            client-secret: ${SUPPLIER_CLIENT_SECRET}
            authorization-grant-type: client_credentials
            scope: inventory:sync
        provider:
          supplier:
            token-uri: https://api.supplier.example.com/oauth2/token

@Service
public class SupplierInventorySync {

    private final OAuth2AuthorizedClientManager clientManager;
    private final RestClient restClient;

    public SupplierInventorySync(OAuth2AuthorizedClientManager clientManager, RestClient.Builder builder) {
        this.clientManager = clientManager;
        this.restClient = builder.build();
    }

    public void syncInventory() {
        OAuth2AuthorizeRequest request = OAuth2AuthorizeRequest
            .withClientRegistrationId("supplier-api")
            .principal("inventory-sync-job")
            .build();

        OAuth2AuthorizedClient client = clientManager.authorize(request);
        String accessToken = client.getAccessToken().getTokenValue();
        // Spring's manager transparently handles requesting and caching this token
        // for its lifetime — application code never manually re-implements the
        // token request/refresh cycle.

        restClient.get().uri("https://api.supplier.example.com/inventory")
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
            .retrieve().body(InventorySnapshot.class);
    }
}

OpenID Connect — the Identity Layer OAuth2 Doesn't Provide

An OAuth2 access token tells a resource server "this bearer is authorized for this scope." It says nothing about who the human behind the request actually is, in a standardized, verifiable way — which is exactly the gap OpenID Connect fills, by adding a third token type to the Authorization Code response: the ID token, a JWT (see the previous page) whose claims describe the authenticated user directly (sub, email, name), signed by the identity provider.

# application.yml — "Sign in with Google" is OIDC, layered on top of OAuth2
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: openid, profile, email   # "openid" scope is what makes this OIDC, not bare OAuth2

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo.oidcUserService(customOidcUserService()))
            )
            .build();
    }
}
TokenDefined byFormatPurpose
Access tokenOAuth2Opaque or JWT — format not mandated by the specPresented to a resource server to authorize a specific API call
ID tokenOpenID ConnectAlways a JWTTells the client who the authenticated user is — never sent to a resource server
Refresh tokenOAuth2Opaque, store-verifiedExchanged for a new access token without re-prompting the user
Never use an access token to identify the user in your own application

An access token's job is authorizing calls to whatever resource server issued it scope for — it may be entirely opaque, and even when it happens to be a JWT, its claims are not standardized to describe identity and the resource server it targets may not even be your own application. Use the ID token's claims — validated the same way any JWT is validated, per the previous page — to establish who the user is inside your own system. Conflating the two is the root cause of a long list of real vulnerabilities where an application accepted an access token meant for one API as if it were proof of identity.

Scopes and Common Misconfigurations

A scope is the unit of consent — request the narrowest scope that accomplishes the task, since every scope granted is permanent standing access until the user or the authorization server revokes it. Requesting broad scopes "in case we need them later" is the OAuth2 equivalent of the over-privileged database credentials it was specifically designed to help avoid.

MisconfigurationConsequence
Loose redirect_uri validation (wildcard or prefix matching) An attacker registers or finds a similar-looking redirect target and captures authorization codes intended for the legitimate client
Storing a confidential client's secret in a public client (mobile app, SPA bundle) The secret is trivially extracted from the shipped binary/bundle — it was never actually secret; use PKCE with a public client instead of pretending it can hold a secret
Using the Implicit or Resource Owner Password Credentials grant Token exposure via browser history/referrers, or reintroducing direct password handling that OAuth2 exists to eliminate
Skipping state parameter validation Opens a CSRF window on the OAuth2 callback itself — an attacker can trick a victim into completing an authorization flow bound to the attacker's own account

Best Practices and Common Pitfalls

✅ Do

  • Use Authorization Code with PKCE for every user-facing client, public or confidential
  • Use Client Credentials for service-to-service calls with no user in the loop
  • Request the narrowest scope that accomplishes the task, not the broadest one "for future flexibility"
  • Validate the state parameter on every callback to prevent CSRF against the authorization flow itself
  • Use the ID token, not the access token, to establish user identity inside your own application
  • Register exact-match redirect_uri values — never wildcards or prefix matches

❌ Don't

  • Don't use the Implicit or Resource Owner Password Credentials grants for anything new — both are deprecated for real, specific security reasons
  • Don't treat OAuth2 alone as authentication — without OpenID Connect's ID token, an access token proves authorization, not identity
  • Don't embed a client secret in a mobile app or SPA bundle — it isn't secret once shipped; use PKCE instead
  • Don't accept an access token as proof of identity in your own application — it may not even be a JWT, and its claims aren't standardized for that purpose
  • Don't request scopes beyond what the current feature actually needs

Interview Questions

🎓 Junior level

Q: Is OAuth 2.0 an authentication protocol?
No — it's a delegated authorization protocol. It lets a client obtain scoped access to a resource on a user's behalf without ever seeing their password, but it doesn't, by itself, tell the client who the user is in a standardized way. "Login with Google" style features are built on OpenID Connect, a separate identity layer added on top of OAuth2.

Q: What are the four roles in OAuth2?
The Resource Owner (the user granting access), the Client (the application requesting it), the Authorization Server (which authenticates the user and issues tokens), and the Resource Server (which hosts the protected data and accepts the token).

Q: What is a scope, and why should you request the narrowest one possible?
A scope defines exactly what a token authorizes — read-only access to shipping data, for example, rather than full account access. Requesting only what's needed limits the damage if that specific token is ever leaked or misused, and it's also what the user is actually consenting to when they approve the authorization request.

🔥 Senior level

Q: Why does current best practice recommend PKCE even for confidential clients that already have a client secret?
A client secret protects the token exchange step — proving to the authorization server that the entity redeeming the authorization code is the legitimate registered client. It does nothing to protect the authorization code itself in transit between steps, which can still be intercepted (a misconfigured redirect, a logged referrer, a compromised network) regardless of whether the client is confidential or public. PKCE adds a second, independent proof — possession of the original code_verifier, generated fresh per authorization attempt and never transmitted until the final exchange — that closes this gap regardless of client type. RFC 9700 formalizes this: PKCE is now recommended universally, not as a public-client-only workaround.

Q: A mobile app team wants to embed their OAuth2 client secret in the app binary to use the standard Authorization Code flow "the same way the web app does." What's wrong with this, and what should they do instead?
A secret embedded in a shipped binary is not a secret — it can be extracted through basic reverse engineering (the binary has to contain it in some retrievable form for the app to use it), so it provides no actual protection while creating a false sense that the client is "confidential." The correct approach is to register the mobile app as a public client with no secret at all, relying entirely on PKCE for the proof-of-possession the secret would otherwise have provided. This is precisely the scenario PKCE was originally designed for before RFC 9700 extended the recommendation to all client types.

Q: An API endpoint validates an incoming Bearer token by checking its JWT signature and treats the "sub" claim as the authenticated user's identity. The token was originally an OAuth2 access token issued for a completely different resource server. What's the vulnerability, and how do you close it?
The access token was never intended to prove identity to this application at all — its claims and its intended audience belong to whatever resource server originally requested it, and OAuth2 access tokens have no standardized identity semantics or audience guarantee unless the API explicitly enforces one. If this API accepts any validly-signed token regardless of its intended audience, a token legitimately issued for an entirely unrelated service — one the attacker separately has legitimate access to — can be replayed here and accepted as proof of identity for a user who never authorized this application at all. The fix is twofold: this application should be using the OpenID Connect ID token — which does carry standardized identity claims and is meant for exactly this — for establishing identity, and any access token accepted for authorization purposes must have its aud claim validated against this specific resource server's expected identifier, rejecting anything issued for a different audience.