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 (draft-ietf-oauth-v2-1-16, 3 September 2026) and is flagged as such everywhere it appears on these pages. It is a working-group consolidation in progress, not a published standard; nothing here should be read as saying otherwise.

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

invalid_request

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 application/json to an endpoint that requires application/x-www-form-urlencoded.

invalid_client

RFC 6749 §5.2

Client authentication itself failed: wrong client_id/client_secret pair, the client used a client-authentication method it is not registered for (sending client_secret_basic credentials to a client configured for private_key_jwt or tls_client_auth), or the client_id does not exist at this issuer at all. Confirm you are pointed at the right realm/tenant before assuming the secret is wrong.

invalid_grant

RFC 6749 §5.2

The catch-all for "this authorization is no longer usable": an authorization code already redeemed or expired, a redirect_uri on the token request that does not match the one used on the authorization request byte-for-byte, a refresh_token rotated out from under you (see refresh-token rotation and reuse detection), or a device code/assertion that expired while you were debugging something else.

unauthorized_client

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 client_credentials, or a client that was never granted the device-code or token-exchange grant on the authorization server’s own client configuration.

unsupported_grant_type

RFC 6749 §5.2

The grant_type value is not implemented by this authorization server. Nearly always a typo (refreshtoken instead of refresh_token) or an extension-grant URN copied slightly wrong — compare it character-for-character against RFC 8628 / RFC 8693 / RFC 7523’s exact string.

invalid_scope

RFC 6749 §4.1.2.1, §5.2

The requested scope is malformed, references a scope that does not exist, or asks for more than this client is allowed. Check for a scope defined on a similarly-named client in a different environment, or a scope that requires administrator pre-approval before any client can request it.

access_denied

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.

invalid_token

RFC 6750 §3.1

Returned by the resource server, in the WWW-Authenticate header, not by the authorization server: the access token is expired, malformed, signed by an unrecognised key, or has the wrong aud/iss. This is the first error to suspect a clock-skew problem on, covered next.

insufficient_scope

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 invalid_token in client error handling — one means "log in again", the other means "this account cannot do that" and re-authenticating will not help.

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: date -u on both the machine that issued the token and the machine validating it, or curl -sI https://auth.example.com | grep -i ^date: compared against local date -u. A few seconds of drift is normal; minutes of drift means NTP is not running or is pointed at something broken.

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.