Beta

Create a tenant

A new tenant starts with its own users, OAuth settings, audit history and logs. You are its first Tenant Admin.

BTL Admin

Tracing and troubleshooting exchanges

A customer writes to the printing company. They selected Connect photo account, approved the printer at the photo service, came back, and saw "We could not finish connecting your photo account." They tried again and it worked. Could someone find out what went wrong the first time?

Part of that attempt passed through the customer's browser and the photo service. Another part ran directly between the printer's backend and the photo service's token endpoint. No single place saw the whole exchange, so the answer depends on records that can be joined together afterwards.

Following one attempt

When the printer creates a pending transaction, it also gives the attempt a local correlation ID, and every log entry about that attempt carries it, from the Connect click to the first API call. The ID is not the state value, which travels through the browser and is better kept out of logs, and it is not anything taken from a session cookie. All values in these examples are fictional:

09:00:00.120  corr=demo-corr-51  connect.start   session=ref-7f3  issuer=https://auth.photos.example
09:00:04.031  corr=demo-corr-52  connect.start   session=ref-7f3  issuer=https://auth.photos.example
09:00:31.874  corr=demo-corr-51  callback        result=code  issuer=match
09:00:32.010  corr=demo-corr-51  token.request   grant=authorization_code
09:00:32.240  corr=demo-corr-51  token.response  status=400  error=invalid_grant  request_id=demo-req-8812

session=ref-7f3 is an internal reference to the printer session, not the cookie value. request_id is the identifier the photo service returned in a response header. Recording it costs nothing, and it is the first thing the photo service's support team will ask for.

The trace narrows the question. invalid_grant can mean an expired code, a reused code, a binding mismatch, or a failed PKCE check, as Errors and denied access said. The token request followed the callback by a fraction of a second, so the code had not expired. This was the attempt's first and only token request, so the code was not reused. But two attempts started in the same session four seconds apart: the customer double-clicked Connect, or opened it in two tabs. If the printer kept one PKCE verifier per session rather than one per attempt, attempt 52 overwrote the verifier for attempt 51, and the photo service correctly rejected the mismatch. The photo service can confirm the reason from demo-req-8812. The fix is the separate per-attempt records described in Correlating requests and responses.

Common failures and their causes

Most problems in an OAuth integration announce themselves in a few recognizable ways:

What you seeWhat it usually means
An error page at the photo service, and the browser never returnsThe requested redirect URI does not exactly match a registered one: a trailing slash, http instead of https, a different path, or a test address sent with the production client ID.
The callback reports an unknown or expired attemptThe transaction expired, the session cookie was not sent on the return journey, or the attempt started on a different host name from the callback, such as www.printer.example, so the callback at printer.example never saw that session.
invalid_grant from the token endpointThe code expired or was already used, the redirect URI differs from the one in the authorization request, or the verifier does not match the challenge.
invalid_client, often with status 401The credential does not match the registration, the client used a method it is not registered for, or the client ID and secret were not form-encoded before being combined for HTTP Basic.
invalid_scopeThe client asked for a scope that does not exist or that its registration does not allow.
401 from the API with error="invalid_token"The token has expired, names a different audience or issuer, or was signed with a key the API has not loaded.
403 from the API with error="insufficient_scope"The token is valid but lacks the scope this operation needs.
Failures on one server only, or only for tokens near expiryA clock on one of the servers involved has drifted.

Each row is a starting point, not a verdict. The authorization server deliberately says little in its errors, so the client's own records of what it sent, minus the secrets, are usually what separates one cause from another.

Reading a rejected token

When the API rejects a token, the token often helps explain why. Suppose the printer's calls start failing with invalid_token, and an engineer decodes one of the rejected tokens on their own machine to compare it with the API's configuration:

Header:
{ "alg": "ES256", "kid": "photos-2026-09", "typ": "at+jwt" }

Claims:
{
  "iss": "https://auth.photos.example",
  "sub": "user-2048",
  "aud": "https://api.photos.example",
  "client_id": "photo-printer",
  "scope": "photos.read",
  "iat": 1790845200,
  "exp": 1790845800,
  "jti": "demo-token-id-7"
}

The claims rule out most of the table. iss and aud match the production API, and kid names a key the API already has. iat and exp say the token was issued at 09:00:00 UTC on 1 October 2026 and expires at 09:10:00. The API's log, found by the token's jti, shows the rejection at what the API believed was 09:10:31, while the printer recorded sending the request at 09:00:31. The token was fine. One API server's clock was ten minutes fast.

Decoding locally needs nothing more than this:

// For reading while debugging. It verifies nothing.
function decodeJwtForDebugging(jwt: string) {
  const [header, claims] = jwt.split('.').slice(0, 2).map(part =>
    JSON.parse(Buffer.from(part, 'base64url').toString('utf8')));
  return { header, claims };
}

As Protecting credentials and messages put it, reading is not validating. Decoded claims say what the token asserts. Only the API's validation says whether to believe them, but for comparing a token with a configuration, reading is exactly what is needed.

Looking without leaking

The browser's developer tools show the redirect half of the exchange. Turn on the network panel's option to preserve the log, because each redirect loads a new page and would otherwise clear the list. The authorization request, the redirect back, and the callback then appear in order, with their parameters.

Everything in that panel is real. The callback address contains a working code until it is used, and the page's requests carry session cookies. Exported network recordings, often saved as HAR files, contain all of it, so they need redacting before they are attached to a support ticket. Reproducing a problem with test accounts and the test client, rather than inside a customer's session, keeps a careless paste from becoming an incident.

Tokens deserve the same care. Pasting an access token into a public token-decoding website hands a working credential to a third party and to whatever that site records. The local function above does the same job without sending anything anywhere. Logs follow the same rule as the trace above: they record the stage, the correlation ID, the client, error codes, and identifiers such as jti and kid, and never the code, verifier, secret, or token. A log that is safe to share is the one that actually gets read when a customer asks what happened.

Try it in the Lab

PUT IT INTO PRACTICE

Check your understanding

Try these questions before moving on. If an answer isn't right, use the feedback and try again.

0 of 2 answered correctly

Enable JavaScript to answer these questions and save progress in this browser.

QUESTION 1 OF 2A token request fails with invalid_grant. What does the error tell you on its own?

QUESTION 2 OF 2You need to look inside a JWT access token while debugging a rejection. What is the safe approach?

We value your privacy

We use cookies and similar technologies to enhance your browsing experience, and analytics to understand our traffic. By clicking "Allow All", you consent to optional analytics. Cookie Policy

Learn identity