OAuth 2.0 — The Complete Guide
OAuth 2.0 without the confusion: the four actors, the grants that still matter, why PKCE is mandatory, refresh token rotation, and what OAuth 2.1 removed.
On this page
OAuth 2.0 is a delegation framework, not a login system, and most of the confusion around it comes from that one misunderstanding. It answers "may this application act on this user's behalf" — not "who is this user", which is what OpenID Connect adds on top.
Key Takeaways
- Four actors: resource owner, client, authorisation server, resource server. Name them and the flows make sense.
- Authorization Code with PKCE is the answer for nearly every interactive case.
- Client Credentials is the answer for machine-to-machine. There is no user involved.
- The
stateparameter is CSRF protection and is not optional. - OAuth 2.1 removes implicit and password grants and makes PKCE mandatory.
The actors
| Actor | Who it is |
|---|---|
| Resource owner | The user who owns the data |
| Client | The application requesting access |
| Authorisation server | Issues tokens after authenticating the owner |
| Resource server | The API that accepts the token |
The distinction between confidential and public clients decides which flows apply. A confidential client — a backend service — can keep a secret. A public client — a single-page app, a mobile app — cannot, because anything shipped to a user's device is readable by that user.
Authorization Code with PKCE
Two mechanisms in that flow are doing security work that is easy to skip.
state is a random value the client generates, sends on the way out, and verifies on the way
back. Without it, an attacker can trick a victim's browser into completing a flow the attacker
started, binding the attacker's account to the victim's session. It is CSRF protection for the
authorisation flow.
PKCE binds the authorisation code to whoever initiated the request. Without it, an attacker who
intercepts the code — via a malicious app registering the same custom URL scheme on a mobile device,
or via browser history — can exchange it for tokens. With PKCE they also need the code_verifier,
which never left the legitimate client.
@Configuration
public class OAuth2ClientConfig {
@Bean
SecurityFilterChain oauthChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login/**", "/oauth2/**").permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth -> oauth
.loginPage("/login")
.userInfoEndpoint(u -> u.userService(customOAuth2UserService))
.successHandler(onboardingSuccessHandler))
.logout(logout -> logout
// RP-initiated logout: end the session at the IdP too, not just here.
.logoutSuccessHandler(oidcLogoutSuccessHandler()));
return http.build();
}
private LogoutSuccessHandler oidcLogoutSuccessHandler() {
var handler = new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);
handler.setPostLogoutRedirectUri("{baseUrl}/");
return handler;
}
}spring:
security:
oauth2:
client:
registration:
acme:
client-id: ${OAUTH_CLIENT_ID}
client-secret: ${OAUTH_CLIENT_SECRET}
authorization-grant-type: authorization_code
scope: 'openid,profile,email,orders.read'
redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
provider:
acme:
issuer-uri: https://auth.acme.comissuer-uri alone is enough — Spring fetches
/.well-known/openid-configuration and discovers the authorisation, token, userinfo and JWKS
endpoints. Hard-coding those four URLs is a common and unnecessary source of environment drift.
Client Credentials
No user, no browser, no redirect. One service authenticating as itself:
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository registrations,
OAuth2AuthorizedClientService clients) {
var provider = OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials()
.refreshToken()
.build();
var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, clients);
manager.setAuthorizedClientProvider(provider);
return manager;
}
@Bean
RestClient inventoryClient(OAuth2AuthorizedClientManager manager) {
var interceptor = new OAuth2ClientHttpRequestInterceptor(manager);
interceptor.setClientRegistrationIdResolver(request -> "inventory-service");
// Tokens are fetched, cached and refreshed automatically — no manual
// token handling in application code.
return RestClient.builder()
.baseUrl("https://inventory.internal")
.requestInterceptor(interceptor)
.build();
}Scope the credentials narrowly. A service that only reads inventory should hold a client whose scopes permit exactly that, so a compromise of that service cannot write orders. Broad scopes on a machine client are the equivalent of running everything as root.
Refresh token rotation
This is the only practical mechanism for noticing that a refresh token has been stolen. Because each token is single-use, a replay means two parties have it. You cannot tell which is the attacker, so the correct response is to invalidate the family and make both re-authenticate — inconvenient for the user, and far better than an attacker with indefinite access.
OAuth 2.1
OAuth 2.1 consolidates a decade of best-practice guidance into the specification itself:
- PKCE is required for the authorization code grant, for every client.
- Implicit grant removed. Tokens in URL fragments leak into history and logs.
- Resource Owner Password Credentials removed. It required the application to handle the user's password directly, which defeats the purpose of delegation and blocks MFA entirely.
- Redirect URIs must match exactly. Wildcard matching enabled open-redirect attacks that leak authorisation codes.
- Refresh tokens must be sender-constrained or rotated.
If you are building today, build to OAuth 2.1. Most of it is what a careful OAuth 2.0 implementation already did.
Scopes and what they actually mean
A scope is a coarse capability the user delegates, not a role the user holds. orders.read says
"this application may read orders on my behalf" — it does not say which orders, and it certainly does
not say the user is an administrator.
That distinction matters because it is a common and serious mistake to treat scopes as authorisation.
A token with orders.read still needs a check that this user may read this order. The scope
bounds what the application may attempt; the resource server decides what the user may see.
Keep scopes few and meaningful. A consent screen listing thirty granular permissions is one nobody reads, and unread consent is not consent.
What to take away
Use Authorization Code with PKCE for anything with a user, Client Credentials for service-to-service,
and nothing else. Always send and verify state. Discover endpoints from the issuer rather than
hard-coding them. Rotate refresh tokens so theft is detectable, and remember that a scope authorises
the application, not the user.
Frequently Asked Questions
Do single-page apps still need PKCE if they have a client secret?
What is the difference between OAuth 2.0 and OpenID Connect?
Why is the implicit grant deprecated?
Related tutorials
- JWT Authentication Deep DiveJWTs done safely: structure and claims, why RS256 beats HS256, key rotation with JWKS, the alg=none and key-confusion attacks, and how to revoke a stateless token.
- Spring Authorization ServerRunning your own OAuth 2.1 and OIDC provider: registering clients, persisting authorizations, JWK sources and key rotation, custom claims, and the consent page.
- In-Memory & JDBC AuthenticationWhere user credentials live: in-memory users for tests, JdbcUserDetailsManager and its schema, writing a custom UserDetailsService, and seeding an initial administrator safely.
- OAuth 2.0 Resource ServerValidating tokens correctly: NimbusJwtDecoder configuration, issuer and audience validators, mapping claims to authorities, opaque token introspection and multi-tenant decoding.