The mental model
OAuth roles, the Authorization Code flow, PKCE, the three kinds of token, and JWT signing, before any code.
You will build
a working model of who trusts whom, with which credential, for what
You will understand
the four OAuth roles, why PKCE exists, how access, refresh and ID tokens differ, scopes versus roles, and why a JWT cannot simply be revoked
Before writing a security configuration, we need a model that lets us answer a simple question whenever the code becomes confusing:
Which party is trusting which other party, for what purpose, and based on which credential?
That question is more useful than memorizing endpoint names.
The four OAuth roles
A typical OAuth deployment has four conceptual roles:
flowchart LR
RO[Resource Owner]
C[Client]
AS[Authorization Server]
RS[Resource Server]
RO -->|uses| C
C -->|asks for authorization| AS
AS -->|issues access token| C
C -->|access token| RS
- Resource Owner — usually the human user.
- Client — the application trying to access something.
- Authorization Server — authenticates/authorizes and issues tokens.
- Resource Server — API that accepts access tokens.
One physical application can play more than one role. In our project, the authorization server also protects its own admin REST endpoints as a resource server.
Authorization Code flow
For a user-facing application, the flow we care about is Authorization Code.
sequenceDiagram
participant U as User
participant B as Browser
participant C as Client
participant AS as Authorization Server
U->>C: Open application
C->>AS: /authorize request
AS->>U: Authenticate + consent
AS-->>C: authorization_code
C->>AS: code + client auth + verifier
AS-->>C: access_token (+ refresh/id token)
The browser never receives the final client secret exchange as part of the front-channel redirect. Instead, the authorization response carries a short-lived authorization code, which is redeemed at the token endpoint.
Why PKCE exists
PKCE is defined by RFC 7636. It binds an authorization request to the party that later redeems the code.
The client creates a random code_verifier and derives a code_challenge:
code_verifier
|
| SHA-256
v
code_challenge
The authorization request sends the challenge:
GET /oauth2/authorize
?response_type=code
&client_id=...
&code_challenge=...
&code_challenge_method=S256
The token request sends the original verifier:
POST /oauth2/token
grant_type=authorization_code
code=...
code_verifier=...
The authorization server hashes the verifier and compares it with the challenge saved with the authorization request.
An attacker who intercepts only the authorization code cannot redeem it without the verifier.
Generate a PKCE pair manually
We will use this throughout the series:
CODE_VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')
CODE_CHALLENGE=$(
printf '%s' "$CODE_VERIFIER" \
| openssl dgst -sha256 -binary \
| openssl base64 -A \
| tr '+/' '-_' \
| tr -d '='
)
Public versus confidential clients
A confidential client can keep a credential secret—for example, a server-side application.
A public client cannot reliably hide a long-term secret—for example, a browser SPA or installed mobile application.
flowchart TD
C{Client can safely hold a secret?}
C -->|Yes| Conf[Confidential client]
C -->|No| Pub[Public client]
Conf --> Secret[client_secret_basic etc.]
Pub --> None[client authentication method: none]
Pub --> PKCE[PKCE is essential]
Our management API will eventually support both types.
Access token, refresh token, ID token
These are different credentials with different audiences.
Access token
An access token represents delegated authorization.
The resource server asks:
Is this token valid and does it contain the authority/scope required for this API?
Refresh token
A refresh token is presented to the authorization server to obtain a new access token without repeating the full browser authorization flow.
Because it can extend a session, it deserves stronger handling than an ordinary short-lived access token. RFC 9700 discusses refresh-token protection and rotation as an important modern security practice.
ID Token
An ID Token belongs to OpenID Connect. It is an identity assertion consumed by the client.
It answers questions such as:
Who authenticated?
Which issuer authenticated them?
Which client was the token intended for?
When was it issued and when does it expire?
Do not use the ID Token as a substitute for an access token when calling APIs.
OAuth scopes versus application roles
We will keep two concepts separate:
OAuth scope: read, write, orders.read
Application role: USER, ADMIN
A scope describes delegated access granted to a client.
A role describes privileges of the authenticated application user in our own authorization-server application.
Our JWT access token will eventually include both:
{
"scope": ["openid", "profile", "read"],
"roles": ["USER", "ADMIN"]
}
That does not make scopes and roles interchangeable.
What OpenID Connect adds
OpenID Connect is an identity layer on top of OAuth.
When the authorization request includes:
scope=openid
we are asking for an OpenID Connect authentication flow.
Important OIDC pieces include:
openid scope
ID Token
UserInfo endpoint
standard claims
provider discovery
Later we will expose preferred_username for profile and email for the email scope.
JWT and signing keys
Our access tokens will be self-contained JWTs.
A JWT has three base64url-encoded parts:
header.payload.signature
For example:
{
"alg": "RS256",
"kid": "..."
}
{
"iss": "http://localhost:9000",
"sub": "user",
"aud": "demo-client",
"scope": ["openid", "read"],
"roles": ["USER"]
}
The authorization server signs with its private key. Consumers verify with the public key, normally obtained through the JWKS endpoint.
flowchart LR
Private[Private RSA Key] -->|sign| JWT[JWT]
JWT --> API[Resource Server]
JWKS[Public JWK / JWKS] -->|verify| API
Never distribute the private key to resource servers.
Self-contained JWTs and revocation
JWTs create an important tradeoff.
A resource server can validate a signed JWT locally without calling the authorization server on every request. That is fast and scalable.
But if the authorization server marks the token revoked in its database, a purely local verifier does not automatically know that. The token’s cryptographic signature is still valid until it expires.
Later we will use two approaches:
- keep access tokens short-lived;
- make our sensitive management APIs also check server-side authorization state.
For other resource servers, introspection is another option.
Trust boundaries for our project
flowchart TB
Internet[Untrusted network]
Browser[Browser]
AS[Authorization Server]
DB[(PostgreSQL)]
Keys[Private signing key]
Internet --> Browser
Browser --> AS
AS --> DB
AS --> Keys
classDef critical stroke-width:3px;
class DB,Keys critical;
The most sensitive assets include:
- user password hashes;
- refresh tokens/authorization state;
- confidential-client secrets;
- private signing keys;
- administrative bearer tokens.
The tutorial’s later design choices—profile-isolated seed data, key files outside Git/Docker images, admin bearer authentication, audit logging—follow from these boundaries.
Modern OAuth security direction
RFC 9700 is especially useful because it captures modern security best practices learned after the original OAuth 2.0 RFC. Our server follows that general direction by using Authorization Code + PKCE, avoiding the implicit flow, rotating refresh tokens, and invalidating authorization state on password changes.
OAuth 2.1 is still a working-group Internet-Draft in September 2026, so think of it as consolidation of the modern OAuth model rather than a finished RFC at the time this tutorial was written.
Checkpoint
You should now be able to explain:
- the difference between client, authorization server, and resource server;
- why Authorization Code does not return the access token through the front channel;
- what PKCE binds together;
- why access, refresh, and ID tokens are different;
- the difference between roles and scopes;
- why JWT revocation is not automatically visible to an offline verifier.
If any of those are unclear, revisit them now. The Spring Security code in later parts will make much more sense once these concepts are stable.
References
- OAuth 2.1 draft: https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/
- OAuth 2.0 Security Best Current Practice — RFC 9700: https://www.rfc-editor.org/rfc/rfc9700
- PKCE — RFC 7636: https://www.rfc-editor.org/rfc/rfc7636
- JWT — RFC 7519: https://www.rfc-editor.org/rfc/rfc7519
- JWK — RFC 7517: https://www.rfc-editor.org/rfc/rfc7517
- OpenID Connect Core 1.0: https://openid.net/specs/openid-connect-core-1_0.html
Next: bootstrap Spring Boot and expose our first real authorization-server endpoints.