Testing and Debugging OAuth
|
This section documents OAuth 2.0 (RFC 6749) as amended by the OAuth 2.0 Security Best Current Practice (RFC 9700 / BCP 240), the OAuth 1.0 Protocol (RFC 5849) for historical context, and OpenID Connect Core 1.0, as published at the IETF Datatracker and the OpenID Foundation specifications — and, on the Spring pages, Spring Boot 4.1.x and Spring Security 7.1.x as published at the Spring Security reference documentation — which are the references these pages are written and verified against. OAuth 2.1 is still an Internet-Draft ( This content was generated with the assistance of AI and should be verified against those official specifications before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Most OAuth bugs are wire-format bugs, not application-code bugs — a missing parameter, a redirect_uri that
does not match byte-for-byte, a client authenticating with the wrong method, a token whose aud does not
match the resource server checking it. The fastest way to isolate one of these is to take the application out
of the loop entirely and drive the authorization server directly with curl, so this page shows exactly that:
copy-pasteable requests for every grant this section documents, how to decode a token’s claims locally without
handing it to a third party, a table translating the nine standard error codes into what they usually mean in
a real deployment, and a disposable local test issuer to run all of it against before touching a shared
environment.
Running each flow by hand with curl
The examples below assume a test issuer at https://auth.example.com with the endpoints
/oauth2/authorize, /oauth2/token, /oauth2/device_authorization, /oauth2/introspect and
/oauth2/revoke — substitute the values published at that issuer’s own
/.well-known/openid-configuration (Discovery,
metadata & client registration) rather than hard-coding paths that happen to match a specific vendor.
Authorization code + PKCE
The authorization request itself has to happen in a real browser — it is the resource owner authenticating
and consenting, and no amount of curl can substitute for that — but everything before and after it is
scriptable. First generate a PKCE pair per RFC 7636 §4.1/§4.2: a 43-to-128-character code_verifier from a
cryptographically random byte string, and its S256 code_challenge, the base64url-encoded (no padding)
SHA-256 hash of the verifier:
CODE_VERIFIER=$(openssl rand -base64 96 | tr -d '=+/\n' | cut -c1-64)
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" \
| openssl dgst -sha256 -binary \
| openssl base64 -A \
| tr '+/' '-_' | tr -d '=')
STATE=$(openssl rand -hex 16)
echo "code_verifier: $CODE_VERIFIER"
echo "code_challenge: $CODE_CHALLENGE"
Build the authorization URL and open it in a browser (open on macOS, xdg-open on Linux):
AUTH_URL="https://auth.example.com/oauth2/authorize?response_type=code\
&client_id=my-app\
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback\
&scope=openid%20profile\
&state=${STATE}\
&code_challenge=${CODE_CHALLENGE}\
&code_challenge_method=S256"
echo "$AUTH_URL"
After logging in and consenting, the browser is redirected to
https://app.example.com/callback?code=<AUTH_CODE>&state=<STATE> — check that state matches what was
sent before doing anything else with the response (Security
best practices) — then paste the code into the token request:
curl -s -X POST https://auth.example.com/oauth2/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=authorization_code' \
-d "code=${AUTH_CODE}" \
-d 'redirect_uri=https://app.example.com/callback' \
-d 'client_id=my-app' \
-d "code_verifier=${CODE_VERIFIER}" \
| jq .
The full parameter set and every error this exchange can return are covered in Authorization Code & PKCE.
Client credentials
No browser step at all — the client authenticates itself and asks for a token directly (Client Credentials & Client Authentication):
curl -s -X POST https://auth.example.com/oauth2/token \
-u 'my-service:s3cr3t' \
-d 'grant_type=client_credentials' \
-d 'scope=orders.read' \
| jq .
Refresh token
curl -s -X POST https://auth.example.com/oauth2/token \
-u 'my-app:s3cr3t' \
-d 'grant_type=refresh_token' \
-d "refresh_token=${REFRESH_TOKEN}" \
| jq .
If the authorization server rotates refresh tokens, the response’s refresh_token replaces the one just
used — keep using the old value again and expect invalid_grant (or, on a server implementing reuse
detection, the whole token family revoked; see
Access & Refresh Tokens).
Device authorization grant
RFC 8628: first request a device and user code, then poll the token endpoint until the user has completed the flow on a second device.
DEVICE_RESPONSE=$(curl -s -X POST https://auth.example.com/oauth2/device_authorization \
-d 'client_id=my-cli' \
-d 'scope=openid profile')
echo "$DEVICE_RESPONSE" | jq .
DEVICE_CODE=$(echo "$DEVICE_RESPONSE" | jq -r .device_code)
INTERVAL=$(echo "$DEVICE_RESPONSE" | jq -r .interval)
echo "Open $(echo "$DEVICE_RESPONSE" | jq -r .verification_uri_complete) and approve the request."
Then poll, honouring authorization_pending and slow_down exactly as RFC 8628 §3.5 specifies — a client
that ignores slow_down and keeps polling at the original interval will usually get rate-limited or blocked
outright:
while true; do
RESPONSE=$(curl -s -X POST https://auth.example.com/oauth2/token \
-d 'grant_type=urn:ietf:params:oauth:grant-type:device_code' \
-d "device_code=${DEVICE_CODE}" \
-d 'client_id=my-cli')
ERROR=$(echo "$RESPONSE" | jq -r '.error // empty')
case "$ERROR" in
authorization_pending) sleep "$INTERVAL" ;;
slow_down) INTERVAL=$((INTERVAL + 5)); sleep "$INTERVAL" ;;
"") echo "$RESPONSE" | jq .; break ;;
*) echo "Device flow failed: $ERROR"; break ;;
esac
done
Full detail, including the expired_token terminal case and the cross-device phishing mitigations of
RFC 10027, is on Device Authorization Grant.
Token introspection
RFC 7662 — ask the authorization server whether a token (opaque or JWT) is still valid, from the resource server’s side, authenticating as itself:
curl -s -X POST https://auth.example.com/oauth2/introspect \
-u 'my-resource-server:rs-s3cr3t' \
-d "token=${ACCESS_TOKEN}" \
-d 'token_type_hint=access_token' \
| jq .
A live token returns {"active": true, …} with its claims; anything expired, revoked, or simply unknown to
this issuer returns {"active": false} — introspection never returns an OAuth error for "not active", only
for a malformed introspection request itself. See
Opaque Tokens, Introspection &
Revocation for the JWT introspection-response variant (RFC 9701) and the caching trade-offs.
Token revocation
RFC 7009 — either an access token or a refresh token can be handed to the same endpoint; revoking a refresh token should also invalidate every access token issued from it, but that guarantee depends on the authorization server’s own implementation:
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://auth.example.com/oauth2/revoke \
-u 'my-app:s3cr3t' \
-d "token=${REFRESH_TOKEN}" \
-d 'token_type_hint=refresh_token'
A 200 with an empty body means the server accepted the request — RFC 7009 §2.2 deliberately returns 200
even when the token was already invalid or unknown, so a revocation call is not a reliable way to check
whether a token existed in the first place; use introspection for that.
Decoding a JWT safely
|
Never paste a production access token, ID token, or refresh token into a web-based JWT decoder. A JWT’s payload is not encrypted — it is only base64url-encoded — so the moment it leaves your machine it has been disclosed in full to whatever server is behind that page, including any personal data, internal identifiers, or scopes it carries, and possibly the raw material needed to replay it before it expires. Decode it locally instead; a JWT’s structure is deliberately simple enough that no library is required for a quick look. |
A compact JWT is three base64url segments separated by . — header, payload, signature
(JWT & the JOSE Family). base64 on most systems expects standard
base64 with padding, so translate the URL-safe alphabet back and pad the string before decoding:
decode_jwt_part() {
local part="$1"
# base64url -> base64, then restore the padding base64 -d expects
part=$(printf '%s' "$part" | tr '_-' '/+')
case $(( ${#part} % 4 )) in
2) part="${part}==" ;;
3) part="${part}=" ;;
esac
printf '%s' "$part" | base64 -d 2>/dev/null | jq .
}
JWT='eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0xIn0.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJhbGljZSIsImF1ZCI6Im9yZGVycy1hcGkiLCJleHAiOjE3NTgwMDAwMDAsImlhdCI6MTc1Nzk5NjQwMH0.c2ln'
echo '--- header ---'
decode_jwt_part "$(echo "$JWT" | cut -d. -f1)"
echo '--- payload ---'
decode_jwt_part "$(echo "$JWT" | cut -d. -f2)"
That shows the claims but proves nothing about authenticity — decoding is not validating. Checking the
signature locally, without any online service, needs the issuer’s public key from its JWKS endpoint
(jwks_uri in the discovery document) and openssl’s own JWS/JWK support, or the validation pipeline
described on JWT & the JOSE Family; on a Spring resource server the
equivalent one-liner is letting `JwtDecoder do it and reading the InvalidBearerTokenException message,
which is exactly what Spring Security’s resource-server support
already wires up.
The standard error codes
Every OAuth error response is a machine-readable error value plus an optional error_description — the table below is the nine codes this section keeps coming back to, what specification defines each, and,
more usefully, what actually tends to cause it when you hit it against a real authorization server rather
than a conformance test.
| Error code | Defined in | What it usually really means in practice |
|---|---|---|
|
RFC 6749 §4.1.2.1, §5.2 |
A required parameter is missing or duplicated, the request repeats a parameter that must appear once, or
the request uses more than one client-authentication mechanism at once. In practice this is almost always a
typo in the request body — a misspelled parameter name, form-encoding a value that should have been raw, or
sending |
|
RFC 6749 §5.2 |
Client authentication itself failed: wrong |
|
RFC 6749 §5.2 |
The catch-all for "this authorization is no longer usable": an authorization |
|
RFC 6749 §4.1.2.1, §5.2 |
Client authentication succeeded, but this client is not permitted to use this grant type — most often a
client provisioned as a public SPA client attempting |
|
RFC 6749 §5.2 |
The |
|
RFC 6749 §4.1.2.1, §5.2 |
The requested |
|
RFC 6749 §4.1.2.1 |
The resource owner (or the authorization server’s own policy) refused the request at the consent or login screen. In automated end-to-end testing this is frequently a test account that never actually reached the login page — a redirect loop, a blocked pop-up, or a conditional-access/risk policy silently declining the attempt rather than a real "no" from a human. |
|
RFC 6750 §3.1 |
Returned by the resource server, in the |
|
RFC 6750 §3.1 |
Also from the resource server: the token is otherwise valid but does not carry a scope this specific
endpoint requires. Worth distinguishing sharply from |
Clock skew
exp (expiry), iat (issued-at) and nbf (not-before) — RFC 7519 §4.1.4, §4.1.6 and §4.1.5 — are all
Unix timestamps compared against the verifier’s clock, not the issuer’s. When the machine validating a
token has drifted from the machine that issued it, a perfectly valid token starts failing with invalid_token
for reasons that have nothing to do with the token, the client, or the authorization server’s configuration — which is why "the token is being rejected right after it was issued" or "the token stopped working exactly at
midnight" is disproportionately often an NTP problem rather than an OAuth problem: a container that boots
without an NTP client, a virtual machine whose host suspended and resumed, or a clock deliberately set wrong
in a test environment.
|
Before debugging the token itself, check the clock: |
Because some drift is unavoidable in distributed systems, resource servers and libraries generally allow a
small leeway — a token is accepted even if exp is a little in the past or nbf/iat a little in the
future, up to a configured tolerance (typically single-digit seconds to a couple of minutes). Spring
Security’s JwtDecoder validation chain exposes this as a configurable clock skew on its timestamp validator
(see the NimbusJwtDecoder / JwtTimestampValidator material in
Spring Security’s resource-server documentation) — widening it
masks a real NTP problem instead of fixing it, so treat a large configured leeway as a smell, not a solution.
A local test issuer
Testing every flow above against a shared environment is slow and, for anything destructive (revocation, expiry, misconfigured clients), risky. A disposable local authorization server removes both problems.
Keycloak in development mode
Keycloak’s own container image ships a start-dev mode meant exactly for this — an in-memory H2 database, no
external Postgres to provision, and an admin console for creating a test realm and client interactively:
docker run --rm -p 8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.0 start-dev
The admin console is then at http://localhost:8080/admin/, and once a realm and client exist its discovery
document is at http://localhost:8080/realms/<realm>/.well-known/openid-configuration — point every curl
example above at that issuer’s actual token_endpoint / device_authorization_endpoint /
introspection_endpoint / revocation_endpoint rather than the placeholder paths used in this page.
start-dev is explicitly documented as unsuitable for production — it disables the production-hardening
checks Keycloak otherwise enforces at boot, which is exactly what makes it fast to iterate with locally.
Spring Authorization Server as the alternative
If the project already runs its own authorization server on
Spring Authorization Server, the simplest
test issuer is often that same application started with a minimal test profile and an in-memory
RegisteredClientRepository — no separate container, no separate protocol implementation to keep in sync
with what production actually runs. Reach for a Keycloak container instead when the goal is testing against
an issuer with a different behaviour than your own service (a second vendor, a stricter OIDC conformance
posture, or a redirect-URI/consent UI to click through manually).
Testcontainers in integration tests
For automated integration tests, either issuer runs well as a Testcontainers-managed container, started and
torn down per test class so the suite has no dependency on a long-lived shared environment. This repository’s
Unit & Integration Testing page already covers the
@SpringBootTest and Testcontainers setup this needs (container lifecycle, @DynamicPropertySource wiring,
choosing what to containerise versus mock) — point that same pattern at a Keycloak or Spring Authorization
Server image instead of restating it here: run the container, read its discovery document at startup, and
register the resulting issuer-uri as the resource server’s spring.security.oauth2.resourceserver.jwt.issuer-uri
(or the client’s spring.security.oauth2.client.provider.* block) via @DynamicPropertySource, exactly as
that page does for a database or message broker container.
OpenID Foundation conformance suites
Once a service’s own OpenID Connect implementation (as an authorization server) needs more assurance than
manual curl testing gives, the OpenID Foundation runs the certification programme and the conformance suite
that backs it: a battery of automated test plans (Basic OP, Implicit OP — historical only, Hybrid OP, FAPI
1.0/2.0, and more) that drive an implementation through every required and optional behaviour in the relevant
specification and report exactly which assertion failed and against which section. Running the suite locally
against a development authorization server, even without pursuing formal certification, catches edge cases
(specific error-response shapes, discovery-document completeness, exact claim requirements) that hand-written
tests tend to skip.