Skip to content
Blog DevOps

How I Verify OAuth2 Credentials Are Ready Before I Write Any Application Code

By Romi Fadli, S.Kom. 23 min read Security & Secrets
On this page
Tiga provider OIDC di kiri terhubung ke satu gerbang verifikasi bergaris putus-putus berisi PKCE, signature, audience, dan logout; satu panah hijau keluar menuju kotak aplikasi yang ditulis sesudahnya.

A login that fails in a half-built application tells you almost nothing. The browser lands on an error page, the logs hold a rejected token, and there are two possible explanations that look identical from where you are standing: the identity provider is configured wrongly, or the service you are writing has a bug. Both produce the same screen.

That ambiguity is expensive at exactly the moment you can least afford it — early, when the application is incomplete and nothing else is proven either. So I stopped starting there. Before the first line of authentication code exists in a new service, I put the credentials through a tool of my own and watch a complete login happen end to end. If that works, the provider is not the suspect any more, and every failure after it belongs to me.

Introduction

This article is about that tool and the decisions inside it. It is not a guide to using it — the code snippets here exist to explain why a choice was made, not to be copied. What I want to convey is the shape of the problem, because the shape turned out to be far less uniform than the specifications suggest.

A. What OAuth2 and OpenID Connect actually are

The two names are used almost interchangeably and they are not the same thing. It is worth a paragraph each, because every obstacle below sits on the boundary between them.

OAuth2 is a protocol for delegated authorisation. Its question is may this application act on someone's behalf? The thing it hands out is an access token — a credential that a client presents to an API to prove it is allowed to do something. OAuth2 by itself says nothing about who the person is. It was never designed to.

OpenID Connect is a thin layer on top of OAuth2 that adds the missing question: who is this? It introduces a second token, the ID token, which is always a JSON Web Token and always describes an authenticated person — their identifier, when they logged in, which client the token was issued for. An OIDC provider is an OAuth2 provider that has agreed to answer both questions.

A JWT is three base64url segments separated by dots: a header naming the signing algorithm, a payload of claims, and a signature over the first two. Anyone can read the payload without a key — it is encoded, not encrypted. Only the signature proves it was not tampered with, and verifying that signature requires the provider's public key.

Those keys are published at a JWKS endpoint, a JSON document listing the provider's public keys. Each key normally carries a kid, a key identifier, and a token's header names the kid that signed it. Match the two and you know which key to verify with. That sentence is the standard arrangement, and three of the six obstacles below are consequences of it not always holding.

B. What actually has to agree between two sides

An OAuth2 client is not a single secret. It is a set of values that must match on both sides of a connection between two systems that are frequently administered by different people. When people say the credentials are ready, this is the list they are implicitly claiming is correct.

The issuer is the provider's identity, a URL that appears inside every token it signs. The endpoints are where to send the user, where to exchange a code, where to fetch the keys, and where to end a session. The client identifier names the application; the redirect URI says where the provider is permitted to send the user back, and it must match what is registered character for character. The scopes declare what is being asked for. And PKCE — a proof mechanism described below — governs how the authorisation code is redeemed.

Any one of these being wrong produces a failure. They do not, however, produce distinguishable failures, which is the whole difficulty.

C. Why a mistake here surfaces far from its cause

Authentication errors are unusually bad at pointing at themselves. A redirect URI that differs by a trailing slash fails at the provider, before your code runs, with a message the user sees and you do not. A scope that was never granted produces a token that works perfectly until some later request needs a claim that is not in it. An audience that does not contain your client identifier produces a token that verifies structurally and gets rejected by your own middleware, which is correct behaviour that looks like a bug.

In each case the moment of the mistake and the moment of the symptom are separated — by a redirect, by a deployment, or by however long it takes for a code path to be exercised. And in a service under active development, the natural assumption when something fails is that the new code is at fault, because the new code is what changed. That assumption is right often enough to be dangerous.

D. Why a generic tool did not answer this

There is no shortage of tools that will perform an OAuth2 flow. I used them, and they solve a different problem from the one I have.

A generic client is built to demonstrate the protocol once. What I need is to run the same rehearsal repeatedly, across several providers, against several configurations, switching between them in the course of an afternoon — because a system that federates identity does not have one provider, it has a list. I also need the rehearsal to be honest about what it verified: not "a token came back", but "this signature was checked against this key, this audience matched this way, and this claim was present".

Hand-assembling that with a general HTTP client is possible and it is exactly the wrong shape of effort. The parts that are hard about OAuth2 — deriving a PKCE challenge, holding a verifier across a browser redirect, choosing the right key from a JWKS document — are the parts that a request-builder cannot help with, and the parts most likely to be got subtly wrong when done by hand at speed.

E. Six obstacles in the way

Stated as a goal: prove that a set of OAuth2 credentials is completely ready before any application depends on them, so that every subsequent failure can be attributed with confidence. Under two constraints. It has to work for several providers at once, provided they follow the specifications — not one provider that I special-case. And it has to be quick enough that I actually do it, because a verification step that takes half an hour is a verification step that gets skipped.

Easy to state, awkward to build. Six obstacles stood in the way, and the thread running through the middle four is the same: providers that genuinely comply with the same specification do not behave the same way.

The first is that no general-purpose tool knows the shape of my configuration. Every target I care about is a different combination of issuer, endpoints, redirect URIs, client identifier and scopes — and I need to move between them without editing anything. The rejected alternative is the obvious one: assembling requests by hand in a general HTTP client, which works once and becomes a transcription exercise the third time. What replaces it is one configuration file per target, selected on the command line, with the providers inside it listed rather than hard-coded.

The second is that PKCE requires one value to survive a round trip through a browser. A random verifier is generated before the user is sent to the provider; its hash travels in the authorisation URL; and the original must be presented, unchanged, when the code is exchanged for tokens — possibly minutes later, after a login form and a redirect. Regenerating it at exchange time is the mistake that suggests itself, and it fails because the hash the provider recorded was derived from the first one. So the verifier is printed and held, deliberately visible, until the exchange consumes it.

The third is that not every access token is a JWT. The natural assumption — that an access token can be split, decoded and verified — holds for some providers and not others. Google issues an opaque access token: a string with no structure to inspect and no signature to check. Treating it as a JWT produces a decoding error that looks like corruption rather than a difference in design. The rejected alternative is to assume the token is always structured; what replaces it is to detect the shape first and, when it is opaque, verify it the only way available — by asking the provider about it and by using it.

The fourth is that a JWKS document does not always identify which key signed the token. The standard arrangement pairs a kid in the token header with a kid in the key set. When the header omits it, a strict implementation has nothing to select on and gives up. The rejected alternative is to trust that kid is always present, which is what most library defaults assume; what replaces it is to prefer the kid match and, failing that, to try each non-encryption key in the set until one verifies.

The fifth is that the library's own key-fetching helper is refused by web application firewalls. The convenience client that ships with the JWT library fetches the key set over urllib, whose default user agent is blocked by a great many protective proxies — the request comes back 403, and nothing about that response suggests the problem is the user agent rather than the credentials. Using the bundled helper is the rejected alternative; what replaces it is fetching the key document directly with an ordinary HTTP client and a user agent that does not look automated.

The sixth is that a correct audience check rejects tokens that are entirely valid. Specification says the client identifier belongs in the aud claim, and a validator that enforces it is doing the right thing. Keycloak, a widely used open-source identity provider, by default does not put the client identifier in the access token's aud at all — it appears in azp, the authorized party. So the rejected alternatives are both tempting and both wrong: trusting aud alone rejects working credentials, and disabling audience validation to make the error go away removes the check that matters. What replaces them is to treat aud as primary and azp as a fallback, and — this is the part that matters for a verification tool — to report which of the two matched, rather than silently accepting either.

The order is one of dependency. The first decides the shape of the tool and everything else lives inside it. The second belongs to the flow itself and has to be right before any token exists to examine. The third, fourth and fifth are all the same discovery arriving in three different disguises — that compliance with a specification does not produce uniform behaviour — and they are ordered by how early each one stops the verification. The sixth is last because it is not about whether a token can be verified but about what verifying it means, and that question only becomes askable once the previous five are answered.

Method

A. One configuration file per target, providers listed inside it

This is the answer to the first obstacle, and it determines the shape of everything after it.

A configuration is a list of issuers. Each issuer carries its endpoints, the redirect URIs registered for it, the clients that belong to it, and the scopes worth requesting. Nothing about any particular provider is compiled into the tool; a provider is data.

json
{
  "issuers": [
    {
      "iss": "https://accounts.example.id/realms/main",
      "endpoints": {
        "jwks":     "https://accounts.example.id/realms/main/protocol/openid-connect/certs",
        "auth":     "https://accounts.example.id/realms/main/protocol/openid-connect/auth",
        "token":    "https://accounts.example.id/realms/main/protocol/openid-connect/token",
        "userinfo": "https://accounts.example.id/realms/main/protocol/openid-connect/userinfo",
        "logout":   "https://accounts.example.id/realms/main/protocol/openid-connect/logout"
      },
      "redirect_uri": {
        "login":  "https://app.example.com/auth/login/callback",
        "logout": "https://app.example.com/auth/logout/callback"
      },
      "clients": [ { "client_id": "app.example.com", "client_secret": "" } ],
      "scopes":  [ "openid", "email", "profile" ]
    }
  ]
}

Two consequences follow, and both are the point. Several issuers can sit in one file, so a system that federates identity across a corporate provider and a public one can be rehearsed in a single sitting. And several files can exist side by side, one per deployment target, selected when the tool starts.

bash
# The target is an argument, not an edit. Switching is a different word,
# not a different file state.
python oauth2.py --config=app.example.com.config.json
python oauth2.py --config=three-providers.config.json

That second line matters more than it looks. A file holding three issuers is how I check that a service intending to accept logins from several providers will actually accept all of them — one after another, in one session, without touching anything between runs.

B. Holding the PKCE verifier across the redirect

This is the answer to the second obstacle. PKCE — Proof Key for Code Exchange — exists so that an intercepted authorisation code is useless to whoever intercepted it. The client invents a secret before it sends the user away, and proves it holds that secret when it redeems the code.

The derivation is deliberately one-way: the challenge that travels publicly in the URL is a hash of the verifier that does not.

python
# The challenge is what the provider stores; the verifier is what proves us
# later. One is derived from the other, never the reverse.
digest    = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")

Two more values are generated alongside it and they answer different questions. state is echoed back by the provider and proves the callback belongs to the request we made; nonce is embedded in the ID token and proves the token belongs to this login rather than a replayed earlier one. A rehearsal that skips either is not rehearsing what the application will do.

Because the tool is interactive and the browser is a separate program, the verifier cannot live in a variable that spans the gap. It is printed, plainly, with an instruction to keep it — and the flow stops there until the callback URL comes back. That is not a limitation worth engineering around: a verification tool benefits from making the mechanism visible rather than hiding it.

C. Detecting a token that cannot be verified by signature

This is the answer to the third obstacle, and it is where the assumption of uniformity first breaks.

The check itself is nearly trivial, and that is what makes the point. A JWT is three dot-separated segments. Anything else is opaque, and no amount of decoding will make it otherwise.

python
def is_jwt(token: str) -> bool:
    # Structure, not content. An opaque token is not malformed —
    # it is a different design, and it needs a different check.
    return len(token.split(".")) == 3

When the token is opaque there is still a question worth answering: is it valid, and does it belong to this client? Both can be established without a signature, by asking the provider directly and then by using the token the way the application will. The first confirms expiry, audience and granted scopes; the second confirms that the token actually opens a resource.

That second step is the one I would not omit. A token that introspects cleanly and then fails to fetch a profile has told you something a structural check never would.

D. Choosing a key when the token does not name one

This is the answer to the fourth obstacle. The ordinary path is to read kid from the token header and find the key with the same identifier. The awkward path is everything else.

python
# Prefer the kid match. Fall back to trying each signing key, because
# some providers omit kid entirely and a strict lookup simply gives up.
keys = [k for k in jwks["keys"] if k.get("use") != "enc"]
for k in keys:
    if token_kid and k.get("kid") == token_kid:
        return public_key_from(k)
for k in keys:                      # no kid, or no match: try them all
    try:
        return public_key_from(k)
    except Exception:
        continue

Two details in there are deliberate. Encryption keys are excluded before anything else, because a key set can legitimately contain keys that were never meant to verify a signature. And the fallback is a genuine fallback rather than the default path — when a kid is present and matches, that match is used, because trying keys in sequence when the provider told you which one to use is guesswork dressed up as robustness.

E. Fetching the key set in a way that is not refused

This is the answer to the fifth obstacle, and it is the one that wasted the most time for the least interesting reason.

The JWT library ships a convenience client that fetches and caches the key set for you. It uses urllib, and a great many protective proxies refuse urllib's default user agent outright. The result is an HTTP 403 in the middle of token validation, which reads as a permissions problem with the credentials — which is exactly the wrong conclusion, at exactly the wrong moment, in a tool whose entire purpose is to tell you whether the credentials are the problem.

python
# Fetched directly rather than through the library's helper: the helper
# uses urllib, and protective proxies reject its user agent with 403.
r = requests.get(jwks_uri, headers={"User-Agent": HTTP_UA}, timeout=15)
r.raise_for_status()
jwks_data = r.json()

The general lesson is worth separating from the specific fix. A diagnostic tool has a stricter requirement than an application: it must not produce failures of its own that resemble the failures it exists to detect. Anything in it that can fail ambiguously is worth replacing with something that fails clearly, even when the replacement is less convenient.

F. Reporting how the audience matched, not merely that it did

This is the answer to the sixth obstacle, and the only one where the specification and the working system disagree.

OpenID Connect requires the client identifier to appear in the aud claim of an ID token, and a validator that enforces this is behaving correctly. Keycloak, in its default configuration, does not place the client identifier in an access token's aud — the client appears in azp instead, the authorized party, which names who requested the token rather than who it is for.

So a strict check reports a failure for credentials that are working. The temptation is to disable audience validation until the error stops, and that trades a false alarm for a missing check.

python
def match_client(aud, azp, client_id):
    # Which claim matched is itself a finding, so return it rather than
    # collapsing both cases into True.
    in_aud = client_id in aud if isinstance(aud, list) else aud == client_id
    if in_aud:          return "aud"     # standard and preferred
    if azp == client_id: return "azp"    # accepted, and worth saying out loud
    return None                          # not for this client at all

Returning which claim matched, rather than a boolean, is what makes this useful in a verification tool. A match on aud is reported as correct. A match on azp is reported as accepted with a note — it means the provider has not been configured with an audience mapper, which is a real finding about the configuration even though nothing is broken. No match at all means the token was issued for somebody else, which is not a warning but a stop.

G. Rehearsing the logout, not just the login

Logging in is half of a session. Ending one is the half that gets tested late, usually in production, usually by a user who is still logged in after clicking the button.

The logout flow is the same shape as the login flow and rehearsed the same way: a URL is built, the browser is sent to it, and the callback is validated when it returns. What it carries is different — an id_token_hint so the provider knows which session to end, a post-logout redirect that must also be registered, and a state to tie the callback to the request.

The validation is where a genuine difference between providers surfaces. state must come back and must match. iss, however, is optional in a logout callback, and Keycloak does not send it.

python
# state is mandatory: a mismatch means this callback is not ours.
if cb_state != state:
    fail("state mismatch")

# iss is optional here. Absent is expected; wrong is an error.
if cb_iss is None:
    note("no iss in logout callback (normal for this provider)")
elif cb_iss != issuer:
    fail("iss mismatch")

A checker that required iss would report a failure on a logout that worked perfectly. Distinguishing absent from wrong is the difference between a tool that tells you something and a tool you learn to ignore.

Results and Discussion

A. What the rehearsal actually establishes

At the end of a successful run the tool has done, in order: built an authorisation URL with a real challenge, validated the callback against the state it generated, exchanged the code using the original verifier, received tokens, fetched the provider's keys, verified both tokens' signatures against those keys, and printed every claim inside them.

That sequence is the whole value, because each step can only happen if the previous one was correct. A token that verifies is proof that the client identifier, the secret if there is one, the redirect URI, the scopes, the PKCE derivation and the key discovery were all right — not individually asserted, but jointly demonstrated.

Which means the claim I actually wanted is now available: when the application fails after this, the provider is not the suspect. That does not narrow the problem to one line, but it removes an entire half of the search space at the moment when the search space is largest.

B. Two compliant providers, two different behaviours

The most useful thing this exercise produced was not the tool. It was the evidence that "supports OpenID Connect" describes a smaller area of agreement than it sounds like it does.

BehaviourKeycloakGoogle
Access token formatJWT, verifiable by signatureOpaque, verified by introspection
kid in token headerAlways presentNot always
Client in access token audAbsent by default; appears in azpPresent
iss in logout callbackNot sentFlow differs entirely

Every row is a place where a reasonable implementation, written against the specification and tested against one provider, breaks when it meets the other. None of the rows is a violation. This is the ordinary consequence of a specification that permits choices, met by two teams who made different ones.

The practical conclusion I draw is that any verification worth doing has to be performed against the provider that will actually be used, not against a representative one. That sounds obvious written down and it is routinely violated by test suites that mock the identity provider entirely.

C. Where this stops being the right tool

It is interactive by design, which makes it unsuitable for continuous integration — it wants a human with a browser, and that is the correct trade for a rehearsal performed a handful of times per service rather than on every commit.

It verifies configuration, not behaviour under load, not session lifetime over days, not token refresh at the boundary of expiry. And it proves the credentials at a moment in time; a provider whose configuration changes next week will need the rehearsal run again, which is cheap precisely because the configuration files are already written.

Conclusion

The thing worth taking from this is not the tool but the sequencing. Verifying an integration before depending on it converts an ambiguous class of failure into an unambiguous one, and that conversion is worth more than the time it costs — particularly early in a service, when everything is unproven and the temptation to blame the newest code is strongest.

The second thing is narrower and I keep meeting it elsewhere: a diagnostic tool must fail more clearly than the thing it diagnoses. A user agent that draws a 403 is not an interesting trap, but it is an instructive one, because it would make the tool lie in exactly the direction it was built to prevent.

Terms

OAuth2
A protocol for delegated authorisation. Answers whether an application may act on someone's behalf, and says nothing about who that someone is.
OpenID Connect
An identity layer on top of OAuth2, adding an ID token that describes the authenticated person.
Access token
The credential presented to an API. May be a JWT or an opaque string, depending on the provider.
ID token
Always a JWT, always about the authenticated person, and the token whose audience must name the client.
JWT
Header, payload and signature encoded as three dot-separated segments. Readable without a key; trustworthy only after the signature is verified.
JWKS
A published set of the provider's public keys, used to verify token signatures.
kid
A key identifier appearing in both a token header and a key set, used to select the verifying key. Not always present.
PKCE
A mechanism binding an authorisation code to the client that requested it, by way of a secret verifier and its published hash.
state
A value echoed back by the provider, proving the callback belongs to the request that was made.
nonce
A value embedded in the ID token, proving it was issued for this login rather than replayed from an earlier one.
aud
The audience claim: who the token is for. The primary check that a token is intended for this client.
azp
The authorized party: who requested the token. A fallback when a provider omits the client from the audience.

Technologies covered

  • Python
  • Keycloak
  • JWT
  • OAuth 2.0
  • OpenID Connect
  • PKCE

About the author

Photo of Romi Fadli

Romi Fadli, S.Kom.

Solution Architect · Backend Engineer · Embedded Systems

In my day job I work as a solution architect. I design the system, lead the team that builds it — quality assurance, backend developers, a UI developer, DevOps and DevSecOps — and write the most critical modules myself, such as a certificate authority, a long-term PDF signer, and the core API of a personnel system.

I work across the whole stack that a running system actually needs. On the hardware side that means PCB design, microcontrollers, and embedded firmware — including RISC-V boards running FreeRTOS and Embassy, speaking MQTT over mutually authenticated TLS on a microcontroller with 64 KB of SRAM.

Most of my day-to-day work is software: Rust backends and API modules built on Clean Architecture, an OAuth2/OpenID Connect authorization server written from scratch, PAdES document signing, and certificate authorities — root CAs, timestamping authorities, and OCSP responders. I also build the frontends that sit on top of them.

Running all of that means infrastructure: Docker Compose stacks, networking, Mikrotik and RADIUS, observability with OpenTelemetry, and a steady habit of writing command-line tools and utilities for Linux when the existing ones do not fit.

Share

Enjoyed this?

Get an email when a new article is published.

Referenced by

A checklist card on the left, a three-part token in the middle with a check mark, and an identity provider marked with a key on the right

An interactive Python script that proves an identity provider is ready for a client: it rehearses a full PKCE sign-in and sign-out like an application, then checks the tokens it receives against the provider's keys the way an application must.

11 min read Read more →

Related Articles

A checklist card on the left, a three-part token in the middle with a check mark, and an identity provider marked with a key on the right

An interactive Python script that proves an identity provider is ready for a client: it rehearses a full PKCE sign-in and sign-out like an application, then checks the tokens it receives against the provider's keys the way an application must.

11 min read Read more →