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
| Role | Who this is | E-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 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();
}
}
| Token | Defined by | Format | Purpose |
|---|---|---|---|
| Access token | OAuth2 | Opaque or JWT — format not mandated by the spec | Presented to a resource server to authorize a specific API call |
| ID token | OpenID Connect | Always a JWT | Tells the client who the authenticated user is — never sent to a resource server |
| Refresh token | OAuth2 | Opaque, store-verified | Exchanged for a new access token without re-prompting the user |
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.
| Misconfiguration | Consequence |
|---|---|
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
stateparameter 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_urivalues — 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
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.
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.