Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Advanced5 min readUpdated
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 state parameter is CSRF protection and is not optional.
  • OAuth 2.1 removes implicit and password grants and makes PKCE mandatory.

The actors

ActorWho it is
Resource ownerThe user who owns the data
ClientThe application requesting access
Authorisation serverIssues tokens after authenticating the owner
Resource serverThe 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

The authorisation code travels through the browser; the token exchange happens back-channel. PKCE binds the code to the client that requested it.

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.

OAuth2ClientConfig.java
@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;
    }
}
application.yml
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.com

issuer-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:

MachineToMachine.java
@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

Rotation makes theft detectable: a reused refresh token means two parties hold it, and exactly one of them is legitimate.

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?
A single-page app cannot have a client secret — anything shipped to the browser is public by definition. That is precisely why PKCE exists: it substitutes a per-request proof for a long-lived secret. Under OAuth 2.1 PKCE is required for every client, confidential ones included.
What is the difference between OAuth 2.0 and OpenID Connect?
OAuth 2.0 is authorisation — it grants an application access to a resource. It says nothing about who the user is. OpenID Connect is a thin layer on top that adds an ID token and a userinfo endpoint, turning it into authentication. If you want to know who someone is, you want OIDC.
Why is the implicit grant deprecated?
It returned the access token in the URL fragment, so it landed in browser history, in referrer headers and in server logs, with no way to authenticate the client. Authorization Code with PKCE gives single-page apps the same capability without exposing the token in a URL.

Related tutorials