00:00

What is OAuth 2.0?

OAuth 2.0 is an open standard that lets one application access a user's data in another application without ever seeing the user's password. Instead of a password, the application receives a short-lived access token that allows only the actions the user agreed to.

Every time you click "Sign in with Google" or allow an app to read your GitHub repositories, OAuth 2.0 is working in the background. In this tutorial you will learn how it works in plain language, which flow to use when, and how to use it in a Spring Boot 3 application.

1. What Is OAuth 2.0?

OAuth 2.0 is an authorization framework. It answers the question: "May this application do this action on this user's behalf?"

The key idea is delegated access. The user stays in control and gives an application a limited, temporary permission, instead of handing over their username and password. The permission can be narrow (for example, "read my calendar" but not "delete events") and can be taken back at any time.

2. A Simple Real-Life Example

Think of a hotel key card. At check-in, the front desk confirms who you are and gives you a card. The card opens only your room and the gym, and only until your checkout date. You never get the master key, and the hotel can cancel your card at any moment.

OAuth 2.0 works the same way. Here is what happens when you click "Sign in with Google" on a photo printing website:

  1. The website sends you to Google's sign-in page.
  2. You sign in on Google's page, so the website never sees your password.
  3. Google asks: "Allow PhotoPrint to see your photos?" You click Allow.
  4. Google sends the website a token that can only read your photos, for a short time.
  5. The website uses that token to fetch your photos and print them.

3. Why Do We Need OAuth 2.0?

Before OAuth, if an app wanted your data from another service, it simply asked for your password. This caused serious problems:

Sharing Your PasswordUsing OAuth 2.0
The app gets full access to your accountThe app gets only the permissions you approve
Access lasts until you change your passwordTokens expire automatically
To block one app, you must change your password everywhereYou can revoke one app without affecting others
Every app stores your password, so one leak exposes itYour password stays with the service you trust
Two-factor login is hard to supportLogin, including two-factor checks, happens on the provider's page

4. The Four Roles in OAuth 2.0

Every OAuth 2.0 exchange involves four parties:

RoleWhat It DoesIn Our Example
Resource OwnerThe user who owns the data and gives permissionYou
ClientThe application that wants access to the dataThe photo printing website
Authorization ServerChecks the user's identity, asks for consent and issues tokensGoogle's sign-in service
Resource ServerHolds the protected data and accepts valid tokensThe Google Photos API

In small systems, the authorization server and resource server can be part of the same application. In larger systems they are usually separate, and popular authorization servers include Keycloak, Okta, Auth0, Microsoft Entra ID and Spring Authorization Server.

5. Important Terms

TermMeaning
Client IDA public name for the application, given when you register it with the authorization server.
Client SecretA password for the application itself. Only server-side apps can keep it safe.
Redirect URIThe address where the authorization server sends the user back after they approve.
ScopeA named permission the client asks for, such as photos.read or email.
ConsentThe screen where the user sees the requested scopes and clicks Allow or Deny.
Authorization CodeA one-time, short-lived code that the client swaps for tokens.
StateA random value the client sends and checks on return, to block forged requests.
Access TokenThe key the client sends to the resource server to access data.
Refresh TokenA longer-lived token used to get new access tokens without a new login.

6. Access Tokens and Refresh Tokens

An access token is like a cinema ticket: it is valid for one show and then expires. The client sends it with every API request in the Authorization header:

HTTP request
GET /api/photos HTTP/1.1
Host: photos.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

The word Bearer means "whoever holds this token may use it", so tokens must be protected like passwords and only sent over HTTPS.

Access TokenRefresh Token
PurposeCall protected APIsGet a new access token
Sent toThe resource server (API)Only the authorization server
Typical lifetimeMinutes (often 5 to 60)Hours, days or weeks
If stolenUseful only until it expiresDangerous, so it must be stored very carefully

An access token can be a JWT (a signed token the API can verify by itself) or an opaque random string (the API asks the authorization server whether it is valid). OAuth 2.0 allows both.

7. How OAuth 2.0 Works Step by Step

The most common and secure flow is the Authorization Code flow. Here it is with real HTTP messages, using a made-up photo app.

Step 1: The client sends the user to the authorization server

Browser redirect
GET https://auth.example.com/oauth2/authorize
    ?response_type=code
    &client_id=photo-app
    &redirect_uri=https://photo.example.com/callback
    &scope=photos.read
    &state=k8Df2xQ
    &code_challenge=RrazcE-fHKlTxaTi-HpO391O8vR7koePATMUSrv_eBE
    &code_challenge_method=S256

(The URL is split across lines only for readability.)

Step 2: The user signs in and approves

The user signs in on the authorization server's page and sees a consent screen listing the requested scopes. They click Allow.

Step 3: The authorization server returns a code

Browser redirect
GET https://photo.example.com/callback?code=Qx7mT2pL9a&state=k8Df2xQ

The client first checks that state matches the value it sent. If not, it rejects the response.

Step 4: The client swaps the code for tokens

This call goes directly from the client's server to the authorization server, not through the browser:

Token request
POST https://auth.example.com/oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic cGhvdG8tYXBwOnNlY3JldA==

grant_type=authorization_code
&code=Qx7mT2pL9a
&redirect_uri=https://photo.example.com/callback
&code_verifier=RNAU_64bPW8ibaFwHjE5cvsggYrXH92FRtzHnvaGbvg
Token response
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "r2Lk9vXp4QmZ...",
  "scope": "photos.read"
}

Step 5: The client calls the API with the access token

The client sends Authorization: Bearer <access_token> to the resource server. The resource server checks the token's signature, expiry and scope, and then returns the photos.

8. PKCE: Protecting the Code

The authorization code travels through the browser, so an attacker might try to steal it. PKCE (Proof Key for Code Exchange, pronounced "pixy") makes a stolen code useless:

  1. Before starting, the client creates a random secret called the code verifier.
  2. It sends only a hash of it, the code challenge, in step 1.
  3. In step 4, it sends the original verifier. The authorization server hashes it and checks that it matches.

An attacker who steals the code does not have the verifier, so they cannot exchange it for tokens. PKCE is now recommended for every client, including server-side apps. Here is how the two values are created in plain Java:

PkceGenerator.java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Base64;

public class PkceGenerator {

    private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding();

    // A random, high-entropy secret that stays with the client
    public static String createCodeVerifier() {
        byte[] randomBytes = new byte[32];
        new SecureRandom().nextBytes(randomBytes);
        return URL_ENCODER.encodeToString(randomBytes);
    }

    // The SHA-256 hash of the verifier, sent in the authorization request
    public static String createCodeChallenge(String codeVerifier) throws NoSuchAlgorithmException {
        MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
        byte[] hash = sha256.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
        return URL_ENCODER.encodeToString(hash);
    }

    public static void main(String[] args) throws NoSuchAlgorithmException {
        String verifier = createCodeVerifier();
        String challenge = createCodeChallenge(verifier);

        System.out.println("code_verifier  = " + verifier);
        System.out.println("code_challenge = " + challenge);
    }
}

In Spring Boot you rarely write this yourself, because Spring Security's OAuth2 client can add PKCE for you. It is still useful to know what happens behind the scenes.

9. OAuth 2.0 Grant Types

A grant type is the method a client uses to get an access token. Choose the one that matches your type of application:

Grant TypeIs a User Involved?Use It ForStatus
Authorization Code + PKCEYesWeb apps, single-page apps, mobile and desktop appsRecommended
Client CredentialsNoBackend services and scheduled jobs calling other APIsRecommended
Refresh TokenNo new loginGetting a fresh access token when the old one expiresRecommended
Device CodeYes, on a second deviceSmart TVs, consoles and command-line tools without a browserRecommended
ImplicitYesOld single-page appsNot recommended
Password (Resource Owner Password)YesOld first-party appsNot recommended

The implicit grant returned tokens directly in the browser URL, where they could leak. The password grant made the app collect the user's password, which is exactly what OAuth was designed to avoid. Use Authorization Code with PKCE instead of both.

10. OAuth 2.0 vs OpenID Connect

OAuth 2.0 is about authorization ("what may this app do?"). It does not define a standard way to tell the app who the user is. OpenID Connect (OIDC) is a thin layer on top of OAuth 2.0 that adds exactly that.

OAuth 2.0OpenID Connect
Main questionWhat can this app access?Who is the user?
Main tokenAccess tokenID token (always a JWT), plus the access token
Token is meant forThe API (resource server)The client application
How to enableRequest API scopesAdd the openid scope
Typical useSecuring APIs"Sign in with Google" style login

A simple rule: use the ID token to learn who logged in, and the access token to call APIs. Never send the ID token to an API as if it were an access token.

11. Spring Boot: Login with Google

Spring Boot makes social login very simple with the OAuth2 Client starter. First, create OAuth credentials in the Google Cloud Console and add this redirect URI to them: http://localhost:8080/login/oauth2/code/google.

pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

Add the credentials as environment variables, never directly in the file:

application.properties
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
spring.security.oauth2.client.registration.google.scope=openid,profile,email

Spring Boot already knows Google's authorization and token addresses, so no other settings are needed. Next, turn on OAuth2 login:

SecurityConfig.java
package com.javabytechie.photos.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2Login(Customizer.withDefaults())
            .logout(logout -> logout.logoutSuccessUrl("/"));

        return http.build();
    }
}

Finally, read the logged-in user's details. Because we requested the openid scope, Spring gives us an OidcUser built from the ID token:

ProfileController.java
package com.javabytechie.photos.controller;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
public class ProfileController {

    @GetMapping("/profile")
    public Map<String, Object> profile(@AuthenticationPrincipal OidcUser user) {
        return Map.of(
                "name", user.getFullName(),
                "email", user.getEmail(),
                "subject", user.getSubject()
        );
    }
}

Start the app and open http://localhost:8080/profile. You are sent to Google, you sign in, and you return to see your name and email. Spring handles the redirect, the state check, the code exchange and the token validation for you.

12. Spring Boot: Protecting an API

An API that accepts access tokens is a resource server. With the resource server starter, Spring Security validates every incoming JWT for you.

pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

Tell Spring where the authorization server is. On startup, Spring reads the server's public keys from this address and uses them to check token signatures:

application.properties
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://auth.example.com

Spring turns each scope in the token into an authority with the prefix SCOPE_. Use these to protect your endpoints:

ResourceServerConfig.java
package com.javabytechie.orders.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders.read")
                .requestMatchers(HttpMethod.POST, "/api/orders/**").hasAuthority("SCOPE_orders.write")
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));

        return http.build();
    }
}

Inside a controller, you can read claims from the validated token:

OrderController.java
package com.javabytechie.orders.controller;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping
    public Map<String, Object> myOrders(@AuthenticationPrincipal Jwt jwt) {
        String userId = jwt.getSubject();            // "sub" claim
        String scopes = jwt.getClaimAsString("scope"); // e.g. "orders.read orders.write"

        return Map.of(
                "user", userId,
                "grantedScopes", scopes,
                "orders", List.of("ORD-1001", "ORD-1002")
        );
    }
}

If the token is missing, expired or has a bad signature, Spring returns 401 Unauthorized. If the token is valid but lacks the required scope, it returns 403 Forbidden.

13. Spring Boot: Service-to-Service Calls

When an Order Service needs data from an Inventory Service with no user involved, it uses the Client Credentials grant. The service logs in as itself with its own client ID and secret.

Add spring-boot-starter-oauth2-client to the calling service and register the client:

application.properties
spring.security.oauth2.client.registration.inventory-client.client-id=order-service
spring.security.oauth2.client.registration.inventory-client.client-secret=${INVENTORY_CLIENT_SECRET}
spring.security.oauth2.client.registration.inventory-client.authorization-grant-type=client_credentials
spring.security.oauth2.client.registration.inventory-client.scope=inventory.read
spring.security.oauth2.client.registration.inventory-client.provider=my-auth-server
spring.security.oauth2.client.provider.my-auth-server.issuer-uri=https://auth.example.com

Next, create a RestClient that fetches a token, caches it, renews it when it expires, and adds it to every request. This uses OAuth2ClientHttpRequestInterceptor, available from Spring Boot 3.4 (Spring Security 6.4):

RestClientConfig.java
package com.javabytechie.orders.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.client.OAuth2ClientHttpRequestInterceptor;
import org.springframework.web.client.RestClient;

@Configuration
public class RestClientConfig {

    @Bean
    public OAuth2AuthorizedClientManager authorizedClientManager(
            ClientRegistrationRepository registrations,
            OAuth2AuthorizedClientService clientService) {

        AuthorizedClientServiceOAuth2AuthorizedClientManager manager =
                new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, clientService);

        manager.setAuthorizedClientProvider(
                OAuth2AuthorizedClientProviderBuilder.builder()
                        .clientCredentials()
                        .build());

        return manager;
    }

    @Bean
    public RestClient inventoryRestClient(RestClient.Builder builder,
                                          OAuth2AuthorizedClientManager manager) {
        return builder
                .baseUrl("http://localhost:8081")
                .requestInterceptor(new OAuth2ClientHttpRequestInterceptor(manager))
                .build();
    }
}

Now each call only needs to name the client registration to use:

InventoryClient.java
package com.javabytechie.orders.client;

import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

import static org.springframework.security.oauth2.client.web.client.RequestAttributeClientRegistrationIdResolver.clientRegistrationId;

@Service
public class InventoryClient {

    private final RestClient inventoryRestClient;

    public InventoryClient(RestClient inventoryRestClient) {
        this.inventoryRestClient = inventoryRestClient;
    }

    public StockLevel getStock(String productCode) {
        return inventoryRestClient.get()
                .uri("/api/stock/{code}", productCode)
                .attributes(clientRegistrationId("inventory-client"))
                .retrieve()
                .body(StockLevel.class);
    }

    public record StockLevel(String productCode, int quantity) {
    }
}

The Inventory Service is a normal resource server (as in section 12) that requires SCOPE_inventory.read.

14. Testing with curl

You can try the Client Credentials flow by hand. First, ask the authorization server for a token:

Terminal
curl -X POST https://auth.example.com/oauth2/token \
     -u order-service:my-secret \
     -d grant_type=client_credentials \
     -d scope=orders.read
Response
{
  "access_token": "eyJraWQiOiJhYmMxMjMiLCJhbGciOiJSUzI1NiJ9...",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "orders.read"
}

Then call the protected API with that token:

Terminal
# 200 OK: valid token with the orders.read scope
curl -H "Authorization: Bearer <access_token>" http://localhost:8080/api/orders

# 401 Unauthorized: no token at all
curl http://localhost:8080/api/orders

Notice there is no refresh_token in the response. In the Client Credentials flow the service can simply ask for a new token when the old one expires.

15. Where Is OAuth 2.0 Used?

  • Social login: "Sign in with Google, GitHub, Microsoft or Facebook" (usually with OpenID Connect).
  • Third-party integrations: a project tool posting messages to your team chat, or an accounting app reading bank data.
  • Public APIs: most large platforms protect their developer APIs with OAuth 2.0.
  • Microservices: services check access tokens and call each other with client credentials.
  • Mobile and single-page apps: secure login without storing passwords on the device.
  • Single sign-on (SSO): one company login for many internal applications.

16. Benefits of OAuth 2.0

  • Passwords stay private: only the authorization server ever sees them.
  • Limited access: scopes give each app only the permissions it needs.
  • Short-lived access: tokens expire, which limits the damage if one leaks.
  • Easy to revoke: users can remove one app's access without changing their password.
  • Central security: login rules, two-factor checks and auditing live in one place.
  • Widely supported: every major platform and framework, including Spring Security, supports it.

17. Security Best Practices

  • Use Authorization Code with PKCE for every app where a user logs in.
  • Always use HTTPS. Bearer tokens sent over plain HTTP can be stolen.
  • Register exact redirect URIs and avoid wildcards.
  • Always check the state parameter (Spring Security does this for you).
  • Ask for the smallest scopes you need, such as photos.read instead of photos.write.
  • Keep access tokens short-lived and protect refresh tokens carefully. Rotate refresh tokens when possible.
  • Keep client secrets out of code. Use environment variables or a secrets manager.
  • Validate every token on the API: signature, issuer, audience, expiry and scope.
  • Avoid storing tokens in browser localStorage. For browser apps, prefer a backend (often called a BFF, Backend for Frontend) that keeps tokens on the server.

18. Common Mistakes to Avoid

  • Treating OAuth 2.0 as a login protocol. Use OpenID Connect when you need to know who the user is.
  • Sending the ID token to APIs. APIs should receive access tokens only.
  • Using the implicit or password grants in new applications.
  • Putting a client secret in a mobile or JavaScript app. Anyone can extract it. These apps are public clients and must use PKCE instead.
  • Skipping the audience check, so a token meant for one API is accepted by another.
  • Logging tokens in application logs or error messages.
  • Asking for every scope "just in case". Users trust apps less when the consent screen asks for too much.

19. Conclusion

OAuth 2.0 lets applications work with a user's data without ever handling their password. The user approves a limited set of scopes, the authorization server issues a short-lived access token, and the API checks that token on every request.

For new projects, keep it simple: use Authorization Code with PKCE when a user is involved, Client Credentials between services, and OpenID Connect when you need login. In Spring Boot, the OAuth2 Client and Resource Server starters handle most of the hard work with just a few lines of configuration.

If you have ever clicked "Sign in with Google", you have already used OAuth 2.0, and now you know exactly what happens behind that button.