All Posts

How OAuth 2.0 Works: The Valet Key Pattern

Stop sharing your password. OAuth 2.0 lets you grant limited access to your data (like 'Read Contacts') without sharing your credentials.

Abstract AlgorithmsAbstract Algorithms
Β·Β·14 min read

AI-assisted content.

TLDR: OAuth 2.0 is an authorization protocol. It lets a third-party app (like Spotify) access your resources (like Facebook Friends) without you giving it your Facebook password. It uses short-lived Access Tokens as scoped, revocable keys.


πŸ“– The Valet Key Pattern: Access Without Passwords

Your car has two keys:

  • Master Key: Opens doors, starts engine, opens trunk and glovebox. (Your Password.)
  • Valet Key: Starts the engine and unlocks only the door. Cannot open the trunk. (Access Token.)

You hand the Valet Key to the parking attendant (Third-Party App). They park the car (do the job) but can't steal the contents of the trunk (access your private data).

This is OAuth 2.0 in one sentence: grant limited, temporary access without sharing your password.


πŸ”’ The Four Roles Every OAuth Flow Has

RoleWho It IsExample
Resource OwnerYou, the userThe person who owns the Facebook account
ClientThe app requesting accessSpotify
Authorization ServerThe identity guardFacebook Login (accounts.facebook.com)
Resource ServerThe API holding your dataFacebook Graph API

The Client never sees your password. It only gets a token from the Authorization Server after you approve the request.


πŸ” Tokens, Scopes, and Why Passwords Are the Wrong Tool Here

Before diving into the flow, it helps to understand why OAuth 2.0 was invented. Historically, if Spotify wanted to read your Facebook friends list it would ask for your Facebook username and password. That's catastrophic β€” Spotify now has full access to your account forever, and you can only revoke it by changing your password.

OAuth 2.0 replaces the password with a token: a short string that proves the user approved access, but only to specific things and only for a limited time.

What a token actually is:

  • A random, unguessable string (or a signed JWT β€” more on that in a follow-up post).
  • Issued by the Authorization Server after you click "Allow".
  • Bound to specific scopes like email or read_friends.
  • Expires in an hour or less.
  • Can be revoked instantly without changing your password.

What a scope is:

A scope is a permission label. When Spotify requests scope=email, it can only read your email. It cannot post on your wall, message your friends, or see your photos. You see exactly what is being requested on the consent screen before you approve anything.

This scoped, time-limited design means that even if a token is stolen, the attacker's window of access is narrow β€” and you can cut it off immediately by revoking the token in your account settings.


βš™οΈ The Authorization Code Flow: Step by Step

This is the most secure and most common grant type. Used whenever the Client is a server-side web app.

sequenceDiagram
    participant U as User (Browser)
    participant C as Client (Spotify)
    participant AS as Auth Server (Facebook)
    participant RS as Resource Server (FB API)

    U->>C: "Login with Facebook"
    C->>AS: Redirect: GET /authorize?client_id=...&scope=email
    AS->>U: Show consent screen: "Spotify wants your email. Allow?"
    U->>AS: Clicks Allow
    AS->>C: Redirect back with ?code=AUTH_CODE
    Note over C,AS: Server-to-server  user never sees this
    C->>AS: POST /token {code, client_secret}
    AS->>C: {access_token, refresh_token, expires_in}
    C->>RS: GET /me/email  Authorization: Bearer ACCESS_TOKEN
    RS->>C: {email: "you@example.com"}

The sequence diagram reveals the two-phase design that makes the Authorization Code flow secure. The first phase (steps 1–4) runs inside the user's browser: the Client redirects the user to the Auth Server, the Auth Server displays a consent screen, and on approval sends a short-lived Auth Code back through the browser's URL β€” intentionally exposed because it expires in seconds and is useless without the client_secret. The second phase (steps 5–6) is a direct server-to-server POST: Spotify's backend sends the Auth Code plus its client_secret to Facebook's token endpoint, and receives an Access Token the browser never sees. The final API call (steps 7–8) then uses that Access Token as a scoped bearer credential to fetch exactly the data the user approved.

Why the extra step (Code β†’ Token)?

The Auth Code appears in the browser's URL bar β€” visible in logs and history. The actual Access Token is exchanged server-to-server where the user/browser never sees it. This prevents token theft via browser history or referrer headers.


πŸ“Š The Authorization Code Flow at a Glance

The sequence diagram above shows the actors. This flowchart shows the decision path β€” what happens at each fork and what can go wrong:

flowchart TD
    A([User clicks Login with Google]) --> B[App redirects browser to /authorize with client_id, scope, redirect_uri, state]
    B --> C[Google shows Consent Screen]
    C --> D{User approves?}
    D -- No --> E([Access Denied  redirect back with error])
    D -- Yes --> F[Google redirects back to app with short-lived Auth Code in URL]
    F --> G[App server POSTs Auth Code + client_secret to /token]
    G --> H{Code valid & not expired?}
    H -- No --> I([Token Error  app must restart flow])
    H -- Yes --> J[Google returns Access Token + Refresh Token + expires_in]
    J --> K[App calls protected API Authorization: Bearer ACCESS_TOKEN]
    K --> L([API returns protected user data])

The flowchart adds the decision branches and failure paths the sequence diagram omits. If the user denies on the consent screen, the flow terminates immediately with an access_denied error redirected back to the app β€” no Auth Code is ever issued. If the server-side code exchange fails (the code is expired, already used, or the client_secret is wrong), the app must restart the entire flow from step one with a freshly generated state nonce. Both failure paths explain why production implementations validate state on every callback and treat Auth Codes as strictly single-use.

Key insight: The user's browser is only involved up to step F. Everything from the Auth Code exchange onward happens server-to-server, invisible to the browser. That separation is what makes this flow secure.


🧠 Deep Dive: Access Tokens, Refresh Tokens, and Scopes

Scopes: Limiting What the Token Can Do

When Spotify redirects you to Facebook, it requests specific scopes:

GET /authorize?scope=email+read_friends

The consent screen shows exactly what Spotify is asking for. You can approve or deny. The token is limited to those scopes β€” Spotify cannot suddenly read your photos.

Token Lifetimes

TokenTypical LifetimePurpose
Access Token1 hourShort-lived bearer credential for API calls
Refresh Token30–90 daysExchange for a new Access Token without user interaction
Auth Code10 minutesOne-time use; expires after the Token exchange

Refresh Without Re-Login

Client β†’ POST /token { grant_type=refresh_token, refresh_token=... }
Auth Server β†’ new Access Token

The user stays "logged in" without re-approving. The Refresh Token itself is rotated on use in modern implementations (rotation prevents replay attacks).


βš–οΈ Trade-offs & Failure Modes: Choosing the Right Grant Type

ScenarioGrant TypeWhy
Web app with server backendAuthorization CodeMost secure; token never in browser
Single-page app (no backend)Auth Code + PKCEReplaces client_secret with PKCE verifier
Machine-to-machine (no user)Client CredentialsService authenticates with its own client_id + secret
Highly trusted first-party appResource Owner PasswordDirect username/password; avoid if possible

PKCE (Proof Key for Code Exchange): Used for public clients (mobile apps, SPAs) that cannot safely store a client_secret. The client generates a code_verifier and code_challenge pair β€” the server verifies the proof without needing a pre-shared secret.

OAuth 2.0 vs OpenID Connect

OAuth 2.0 answers: "Can this app access this resource?"
OpenID Connect (OIDC) adds: "Who is this user?" β€” it returns an ID Token (JWT) with the user's identity alongside the access token. Most "Login with Google" buttons use OIDC on top of OAuth 2.0.


🧭 Decision Guide: Which OAuth Grant Type to Use

  • Web app with server backend: Authorization Code flow.
  • SPA or mobile app: Authorization Code + PKCE.
  • Service-to-service (no user): Client Credentials.
  • Need user identity: Add OpenID Connect on top.

Avoid the Password grant β€” it requires your app to handle user credentials directly.

πŸ“Š Client Credentials Flow (Machine-to-Machine)

sequenceDiagram
    participant S as Service (Client)
    participant AS as Auth Server
    participant RS as Resource Server

    S->>AS: POST /token {client_id, client_secret, grant_type=client_credentials}
    AS-->>S: {access_token, expires_in}
    S->>RS: GET /api/data Authorization: Bearer ACCESS_TOKEN
    RS-->>S: 200 {data}
    Note over S,AS: No user involved  service authenticates itself

Unlike the Authorization Code flow, the Client Credentials flow has no user, no browser redirect, and no consent screen. The Service authenticates directly to the Authorization Server using its own client_id and client_secret, and receives an Access Token in a single round-trip β€” there is no human in the loop to approve anything. The Service then presents that token as a bearer credential on every call to the Resource Server, making this the correct grant type for machine-to-machine scenarios like a billing service calling a payments API at midnight or a CI pipeline fetching deployment secrets.


πŸ›‘οΈ Common Security Pitfalls

PitfallWhy It MattersMitigation
Token in URL fragmentVisible in browser history and server logsAlways exchange Auth Code server-side
Missing state parameterEnables CSRF attacks during the redirectGenerate and validate a random state nonce
Long-lived access tokensStolen tokens stay valid longerShort expiry (1h) + refresh token rotation
Overly broad scopesExposes more data than neededRequest minimum scopes; show clear consent

🌍 Real-World Applications: Where You See OAuth 2.0 Every Day

OAuth 2.0 is not an abstract protocol β€” it runs silently behind nearly every "Login with…" button you have ever clicked.

"Login with Google" on any website. When you click that button, the website (Client) sends you to Google's Authorization Server. You see a consent screen ("This site wants to see your name and email address"). You click Allow. Google sends the site a token. The site calls the Google People API to fetch your profile. You never typed your Google password into the third-party site.

Spotify reading your Facebook friends. Spotify requests scope=user_friends. Facebook shows you a consent dialog listing exactly what Spotify will access. You can allow the friends list but deny access to your photos. Spotify gets a token scoped to just what you approved.

Mobile apps calling APIs. The Twitter mobile app uses OAuth 2.0 with PKCE to get a token that lets it post tweets on your behalf. Because the app can't safely store a client_secret, PKCE replaces it with a cryptographic proof. The token expires in an hour; the refresh token silently renews it so you stay logged in.

Machine-to-machine services. A billing microservice that needs to call a payments API at midnight β€” no user involved β€” uses the Client Credentials grant. The service authenticates with its own client_id and client_secret and gets a token scoped only to the payments endpoint. No human consent screen required.

In every case the pattern is the same: the resource owner (you, or an admin who pre-approved) grants a scoped, revocable token to a client β€” without sharing the master credential.


πŸ§ͺ Implementing OAuth 2.0: Your First Integration and Common Mistakes

If you are adding "Login with Google" to a web app for the first time, here is the practical path:

Step 1 β€” Register your app. Go to the Google Cloud Console (or any provider's developer portal) and create an OAuth client. You will receive a client_id (public) and client_secret (keep this private β€” never commit it to git).

Step 2 β€” Build the redirect URL. Your login button should redirect the user to:

https://accounts.google.com/o/oauth2/v2/auth
  ?client_id=YOUR_CLIENT_ID
  &redirect_uri=https://yourapp.com/callback
  &response_type=code
  &scope=openid email profile
  &state=RANDOM_NONCE

Step 3 β€” Handle the callback. Google redirects back to /callback?code=AUTH_CODE&state=.... Verify the state matches what you sent (CSRF protection), then exchange the code for a token server-side.

Step 4 β€” Exchange the code for a token. POST to Google's token endpoint with the code and your client_secret. Store the resulting tokens securely β€” access token in memory, refresh token in a server-side database.

Common beginner mistakes to avoid:

  • Storing the client_secret in frontend code or git. It is a private credential. Use environment variables.
  • Skipping the state parameter. Without it, an attacker can trick a user into linking their account to the attacker's session (CSRF).
  • Using too broad scopes. Only request what you actually need. Users see the consent screen β€” asking for everything looks suspicious and reduces conversion.
  • Forgetting token expiry. Access tokens expire. Build in refresh token logic from day one, or your app will fail silently after an hour.

πŸ› οΈ Spring Security OAuth2 & Keycloak: Protecting a Spring Boot API with Bearer Tokens

Spring Security OAuth2 Resource Server adds bearer-token validation to any Spring Boot application in two lines of configuration β€” no manual JWT parsing or signature verification code. When a request arrives with Authorization: Bearer <token>, Spring Security fetches the Authorization Server's JWKS endpoint, validates the token signature, and populates the SecurityContext automatically.

Keycloak is the most popular open-source Authorization Server implementing the full OAuth 2.0 + OIDC spec. It provides a user management UI, realm-based multi-tenancy, and can run locally in Docker for development β€” making it a drop-in test implementation of the Authorization Server role described in this post.

// build.gradle:
//   org.springframework.boot:spring-boot-starter-security
//   org.springframework.boot:spring-boot-starter-oauth2-resource-server

// ── Security configuration ─────────────────────────────────────────────────────
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .anyRequest().authenticated()  // every other endpoint needs a valid token
            )
            .oauth2ResourceServer(oauth2 ->
                // Spring fetches the Authorization Server's JWKS endpoint,
                // verifies the token signature with the public key, then populates SecurityContext.
                // No manual JWT parsing β€” the issuer-uri in application.yml drives discovery.
                oauth2.jwt(Customizer.withDefaults())
            )
            .build();
    }
}

// ── Controller that inspects JWT claims β€” shows the OAuth2 token anatomy ────────
@RestController
@RequestMapping("/api")
public class OrderController {

    private static final Logger log = LoggerFactory.getLogger(OrderController.class);

    // @PreAuthorize checks the "scope" claim inside the JWT.
    // Spring Security maps JWT scope β†’ SCOPE_<value> authorities automatically:
    //   scope="orders:read profile:read"  β†’  [SCOPE_orders:read, SCOPE_profile:read]
    @GetMapping("/orders")
    @PreAuthorize("hasAuthority('SCOPE_orders:read')")
    public List<Order> getOrders(JwtAuthenticationToken auth) {

        // ── What's actually inside the JWT the client sent ──────────────────
        Jwt jwt = auth.getToken();

        String  sub    = jwt.getSubject();                       // "sub"   β†’ Resource Owner's stable identity
        String  iss    = jwt.getIssuer().toString();             // "iss"   β†’ Authorization Server that issued this token
        Instant exp    = jwt.getExpiresAt();                     // "exp"   β†’ when this access token expires (short-lived)
        String  aud    = jwt.getAudience().toString();           // "aud"   β†’ which Resource Server this token is for
        List<String> scopes = jwt.getClaimAsStringList("scope"); // "scope" β†’ permissions the user consented to grant

        // Example token payload (decoded):
        //   { "sub": "user:42", "iss": "https://auth.example.com",
        //     "exp": 1709985600, "aud": ["orders-api"],
        //     "scope": "orders:read profile:read" }
        log.debug("Token: iss={} sub={} scopes={} exp={}", iss, sub, scopes, exp);

        // "sub" is the OAuth2 Resource Owner ID β€” use it, not a session cookie
        return orderService.findByUser(sub);
    }

    @PostMapping("/orders")
    @PreAuthorize("hasAuthority('SCOPE_orders:write')")
    public Order createOrder(@RequestBody OrderRequest req, JwtAuthenticationToken auth) {
        // Attach the Resource Owner's identity ("sub") to the new order for ownership checks
        return orderService.create(req, auth.getToken().getSubject());
    }
}
# application.yml β€” point Spring Security at your Keycloak realm
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # Spring fetches JWKS from this URL to verify token signatures automatically
          issuer-uri: http://localhost:8080/realms/my-realm
# Run Keycloak locally with Docker β€” acts as the Authorization Server from this post
docker run -p 8080:8080 \
  -e KEYCLOAK_ADMIN=admin \
  -e KEYCLOAK_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:24.0 start-dev

The SCOPE_orders:read prefix in @PreAuthorize maps directly to the scope concept from this post. Spring Security extracts the JWT's scope claim and creates SCOPE_<value> authorities automatically β€” so scope=orders:read in the token becomes the SCOPE_orders:read authority enforced by the annotation.

For a full deep-dive on Spring Security OAuth2 Resource Server, Keycloak realm configuration, and JWT claim customization, a dedicated follow-up post is planned.


πŸ“š Lessons Every Developer Learns from Their First OAuth Integration

OAuth 2.0 has a reputation for being confusing. These are the lessons that clear it up:

OAuth 2.0 is authorization, not authentication. It answers "Can Spotify read my email?" β€” not "Is this really Spotify?" or "Is this really you?" For user identity, you need OpenID Connect (OIDC) layered on top.

Tokens are not magic β€” treat them like passwords. An access token in the wrong hands is as dangerous as a password, for the duration of its validity. Store them securely. Never log them. Never put them in URLs.

The consent screen is the user's control panel. This is the moment users decide what access to grant. Design your scope requests to be minimal and honest. Users who trust the consent screen are users who stay.

Short-lived tokens are a feature, not a bug. An hour-long access token limits the blast radius of a breach. Pair it with a refresh token to keep sessions smooth without sacrificing security.

PKCE is not optional for public clients. If your app cannot safely store a client_secret (mobile app, single-page app), use PKCE. It is mandatory in OAuth 2.1 (the upcoming revision of the spec).


πŸ“Œ TLDR: Summary & Key Takeaways

  • OAuth 2.0 = authorization, not authentication. "Can Spotify see my email?" not "Is this Spotify?" or "Is this really you?"
  • Authorization Code Flow: redirect β†’ consent screen β†’ auth code β†’ server-side token exchange β†’ access token.
  • Access Tokens are scoped and short-lived (β‰ˆ1 hour); Refresh Tokens allow silent renewal without re-prompting the user.
  • PKCE replaces client_secret for public clients (mobile apps, SPAs) β€” and is required in OAuth 2.1.
  • OpenID Connect (OIDC) adds user identity (id_token) on top of OAuth 2.0; most "Login with Google" buttons use OIDC.
  • Always validate the state parameter on the callback to prevent CSRF attacks.
  • Request the minimum scopes your app actually needs β€” users trust transparent consent screens.


Share

Test Your Knowledge

🧠

Ready to test what you just learned?

AI will generate 4 questions based on this article's content.

Abstract Algorithms

Written by

Abstract Algorithms

@abstractalgorithms