
OAuth 2.0 and OpenID Connect: A Practical Walkthrough
A practical deep dive into OAuth 2.0, OpenID Connect, and PKCE through the lens of securing Spring Boot microservices. This article explores delegated authorization, authentication, JWTs, gateway security, and the design decisions behind modern identity systems by following the questions that naturally arise while building a real-world architecture.
OAuth 2.0 and OpenID Connect
How I actually learned this — by questioning my own gateway design first, then working backward into the protocol.
Credit where it's due: the core framing here — the delegated-authorization problem, the front-channel/back-channel distinction, and PKCE — builds on Nate Barbettini's talk "OAuth and OpenID Connect (in Plain English)." It's genuinely one of the most insightful explanations of this topic I've come across. Talk: youtube.com/watch?v=996OiexHze0 · Nate Barbettini: linkedin.com/in/nbarbettini
The design decision that started all of this
While securing a Spring Boot microservices setup, I made a call that felt clean at the time: authenticate the user only at the API Gateway, and expose only the gateway to the outside network. Every internal service — user service, orders, ratings, whatever — sits behind it, unreachable from outside.
flowchart TD
Client([Client]) --> Gateway[API Gateway\nAuth happens here]
Gateway --> S1[Service A]
Gateway --> S2[Service B]
Gateway --> S3[Service C]
The appeal is obvious: the internal services stay lightweight — no Spring Security dependency, no filter chain, no token-parsing logic duplicated everywhere. One place owns authentication. Everything downstream just trusts the gateway.
But the more I sat with it, the more it felt like the network boundary was doing all the work, and boundaries fail in ways that are easy to underestimate:
- A misconfigured route or an internal tool can end up hitting a service directly, bypassing the gateway entirely.
- A compromised container on the same cluster or VPC doesn't need to go through the gateway at all — it's already "inside."
- Lateral movement: once any internal service is breached, an attacker sitting inside the network has no further authentication to defeat, because nothing downstream was ever checking.
- The gateway becomes a single point of failure for security, not just for routing. One bug in the gateway's auth filter, and every service behind it is silently wide open.
This is the core tension between perimeter security and zero trust. Gateway-only auth is perimeter security — it assumes the network boundary is trustworthy, and everything inside it can trust each other by default. Zero trust says the opposite: assume the network is already hostile, and verify identity at every hop, not just the edge.
The practical middle ground I landed on — and the one most production systems actually use — isn't "put Spring Security on every service." It's this: let the gateway do the heavy lifting (redirects, login, talking to the identity provider), but have it forward a signed token downstream that each service can verify cheaply, without needing the full authentication stack.
flowchart TD
Client([Client]) --> Gateway[API Gateway\nHandles login + issues/forwards JWT]
Gateway -- signed JWT --> S1[Service A\nverifies signature only]
Gateway -- signed JWT --> S2[Service B\nverifies signature only]
Verifying a JWT's signature is cheap — it's a local cryptographic check against a public key, not a network call back to the gateway or the identity provider, and not a full Spring Security login flow. That gets you defense-in-depth without making every internal service heavyweight. It's a smaller ask than what I originally assumed the trade-off was.
Getting to that conclusion meant actually understanding what a gateway is delegating in the first place — which is where OAuth 2.0 and OpenID Connect come in.
"Wait, is Auth0 just... OAuth?"
First mix-up, and a common one. OAuth 2.0 is a protocol — a standard describing how an application obtains permission to act on a user's behalf. Auth0 is a company that implements that standard as a hosted product: login screens, a user directory, MFA, token issuing. Same relationship as HTTP (a protocol) to any particular web server (an implementation of it). Keycloak, Okta, Microsoft Entra ID, and Amazon Cognito are all doing the same job through their own implementations.
That's a vocabulary problem, though. The real question was bigger.
What OAuth is actually solving
Picture a note-taking app that wants to back up your files to your personal cloud storage account. The naive, dangerous way to build that: ask the user for their cloud storage username and password directly, log in on their behalf, do the backup, then (hopefully) throw the password away. This is exactly the pattern several real products used in the early web era, and it's a bad one — that password is usually also the recovery path for a person's bank, their other accounts, everything downstream of that one login.
OAuth 2.0 exists to remove the password from that exchange entirely. Instead of the note-taking app ever seeing your cloud storage credentials, you're redirected to the storage provider's own login page, you authenticate there, and the provider hands the note-taking app a limited, revocable token — never the password itself.
sequenceDiagram
participant U as User (Resource Owner)
participant App as Note App (Client)
participant AS as Identity Provider (Authorization Server)
participant API as Storage API (Resource Server)
U->>App: Click "Connect storage account"
App->>AS: Redirect to login
U->>AS: Enter credentials (App never sees these)
AS-->>U: "Allow this app to access your files?"
U->>AS: Approve
AS->>App: Access Token
App->>API: Request file upload (Bearer token)
API-->>App: Confirmed
The four roles worth internalizing:
- Resource owner → You. Whoever owns the data and can grant access.
- Client → The note-taking app. The application requesting access.
- Authorization server → The storage provider's login system. Where login and consent happen.
- Resource server → The storage provider's file API. Where the actual data lives.
OAuth only defines how to obtain, refresh, and revoke that access token. It says nothing about how login itself looks, or what information comes back about the person who logged in — which turned out to matter a lot, later.
"Why do I get a code first, and not the token immediately?"
This is where front channel and back channel stopped being jargon and started being a genuinely useful mental model.
A back channel is server-to-server communication that never touches the user's browser — encrypted, private, effectively invisible to anyone watching network traffic in the browser. A front channel is anything routed through the browser: redirects, the address bar, query parameters. The browser is secure in the TLS sense, but it isn't a vault — view-source, dev tools, or a compromised extension can all see what passes through it.
flowchart LR
subgraph Front[Front Channel — through the browser]
A1[Redirect to login] --> A2[Consent screen] --> A3[Redirect back with a code]
end
subgraph Back[Back Channel — server to server]
B1[Send code + secret] --> B2[Authorization Server]
B2 --> B3[Access Token returned]
end
So the flow is deliberately split: the front channel handles the parts that need a human — login, consent — and hands back only a short-lived, single-use authorization code, not the actual access token. That code is exchanged for a real token on the back channel, where the client also proves its own identity with a client secret that never leaves the server.
Even if someone intercepted the code off the front channel, it's useless without that secret. That's the entire reason the exchange is a two-step dance instead of a single request.
"But mobile apps and single-page apps can't keep a secret — so how does that work?"
This is the doubt that led me to PKCE (Proof Key for Code Exchange), and it took a few passes to actually land.
A backend server can hide a client secret in an environment variable no one else can read. A mobile app or a browser-based single-page app can't — anyone can decompile the app or open dev tools and find it. PKCE replaces the fixed secret with a one-time, per-login secret instead.
sequenceDiagram
participant App as Public Client (mobile / SPA)
participant AS as Authorization Server
App->>App: Generate random Code Verifier
App->>App: Hash it → Code Challenge
App->>AS: Send Code Challenge, request login
AS-->>App: Authorization Code
App->>AS: Authorization Code + Code Verifier
AS->>AS: Hash the Verifier, compare to stored Challenge
AS-->>App: Match → Access Token issued
A few doubts came out of that diagram, in order:
If the Code Challenge is sent openly, isn't that already exposed? No — it's a one-way hash of the verifier. Seeing the hash doesn't let anyone reconstruct the original value, the same way seeing a fingerprint doesn't hand you the finger.
What if someone actually gets the Code Verifier? It only matters paired with a stolen authorization code, and even then it depends on where the attacker is standing. Over the network, it's protected by TLS. On a fully compromised device, PKCE isn't the layer that was ever going to save you — nothing would. In your own browser's dev tools, seeing your own verifier is expected; PKCE was never trying to hide it from you, only from a different app or a code-interceptor trying to finish the exchange without it.
If the verifier only lives for one login, how does it protect anything afterward? It doesn't need to — that's the point. PKCE's job ends the moment the token exchange succeeds. Every subsequent API call runs on the access token alone; a fresh verifier/challenge pair gets generated the next time login happens. It behaves like a one-time code during a bank login: you don't re-enter it every time you check your balance afterward.
"So the access token lasts forever once I have it?"
No — and the two tokens involved do two different jobs:
- Access Token — short-lived, usually minutes to an hour, scoped to specific permissions, easy to revoke.
- Refresh Token — longer-lived, used to silently obtain a new access token without forcing the user to log in again.
sequenceDiagram
participant App
participant AS as Authorization Server
participant API as Resource Server
App->>API: Call API with Access Token
API-->>App: Data returned
Note over App,API: ...time passes, token expires...
App->>API: Call with expired token
API-->>App: 401 Unauthorized
App->>AS: Present Refresh Token
AS-->>App: New Access Token
App->>API: Retry successfully
And even within its lifetime, a token can only do what its scope allows — a token issued for read-only access gets a flat rejection the moment it attempts a write or delete.
"Doesn't that just mean the tokens are sitting there, exposed?"
Yes — and the honest framing is that OAuth doesn't try to make tokens unstealable. It tries to limit the blast radius if they are stolen:
- Access tokens expire quickly on purpose, so a stolen one only works for a short window.
- Refresh tokens are treated as far more sensitive, since they can mint an unlimited stream of new access tokens.
- Refresh Token Rotation: every time a refresh token is used, the server issues a brand-new one and immediately kills the old one.
- Reuse detection: if that now-dead refresh token gets used again by someone else, that's a strong signal of theft — the server can revoke the whole session and force re-authentication.
sequenceDiagram
participant App
participant AS as Authorization Server
participant Attacker
App->>AS: Use Refresh Token #1
AS-->>App: Access Token #2 + Refresh Token #2 (Token #1 now dead)
Attacker->>AS: Try Refresh Token #1 (stolen earlier)
AS-->>Attacker: Rejected — reuse detected, session revoked
Where the refresh token actually lives also changes the risk profile a lot:
- Backend server (e.g., Spring Boot) → Server-side only, browser never sees it — the safest setup.
- Browser-based SPA → Requires rotation + reuse detection if used at all.
- Mobile app → OS-protected storage (Android Keystore / iOS Keychain), never plain files.
Where OpenID Connect enters
None of what's above answers a very basic question: once a token is issued, does the application actually know who the person is? OAuth by itself doesn't standardize that — a bare access token only proves permission was granted, not identity.
OpenID Connect (OIDC) is a thin identity layer on top of OAuth 2.0. Requesting the openid scope during login gets you, alongside the access token, an ID Token — a signed JWT carrying standardized identity claims. Put simply, OIDC takes what OAuth already answers and adds one more question on top of it:
flowchart LR
OAuth["OAuth 2.0\nAnswers: is this allowed?"] -->|adds ID Token + standardized claims| OIDC["OpenID Connect\nAnswers: is this allowed? + who is this?"]
In practice, that ID Token looks like this once decoded:
{
"sub": "user-12345",
"name": "Jumaan",
"email": "jumaan@example.com",
"iat": 1735689600,
"exp": 1735693200
}
An ID Token is a JWT — header, payload (claims), and a signature. That signature is what lets the receiving service verify the token was genuinely issued by the trusted authorization server and hasn't been tampered with, without necessarily calling back to check. That's exactly the property that makes JWT validation at the gateway or at individual services cheap enough to be worth doing.
Bringing it back to the gateway question
Once all of this clicked, the original design decision looked different — not wrong, but incomplete on its own:
flowchart TD
U[User] -->|OIDC login| AS[Identity Provider]
AS -->|Access Token + ID Token| Gateway[API Gateway]
Gateway -->|forwards signed JWT| S1[Service A - verifies signature]
Gateway -->|forwards signed JWT| S2[Service B - verifies signature]
Authenticating only at the gateway keeps every internal service simple, and that's a real, legitimate benefit — not every service needs the full weight of a security framework. But it also means the gateway is the only thing standing between "authenticated" and "not," and network boundaries get bypassed in ways that don't require breaking any authentication at all. Forwarding a signed JWT downstream, and letting each service do a lightweight signature check instead of a full login flow, keeps most of the original benefit while closing that specific gap.
Putting the whole picture together
Zooming all the way out, here's the complete path a request takes, end to end — from a user logging in to a query actually reaching a database:
flowchart LR
U[Client App] -->|OIDC login| IDP[Identity Provider]
IDP -->|Access Token + ID Token JWT| U
U -->|Bearer JWT| GW[API Gateway]
GW -->|forwards JWT| M1[Microservice A]
GW -->|forwards JWT| M2[Microservice B]
M1 -->|verifies signature locally| DB1[(Database)]
M2 -->|verifies signature locally| DB2[(Database)]
Nothing downstream of the Identity Provider ever needs to call it back to check a token's validity — the signature on the JWT is enough. That's what makes this shape scale: one login, one token, verified independently and cheaply at every hop that needs it.
The short version
- OAuth 2.0 — lets an application act on a user's behalf without ever seeing their password. Answers "is this allowed?"
- OpenID Connect — adds a standardized ID Token on top of OAuth, so an app can also answer "who is this?"
- Front channel — anything routed through the browser: redirects, login, consent.
- Back channel — direct server-to-server calls, invisible to the browser, where the real token exchange happens.
- PKCE — a one-time, per-login proof that lets public clients (mobile apps, SPAs) skip storing a fixed secret.
- Access Token — short-lived key for calling APIs.
- Refresh Token — longer-lived key for renewing access, protected with rotation and reuse detection.
- Gateway-only authentication vs. zero trust — expose only the gateway if you want lightweight internal services, but forward a signed JWT downstream so each service can still verify identity independently, cheaply, without owning the full authentication flow itself.