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

Implementing a client

The printing company is rebuilding its photo connection. The first version grew one function at a time: a handler for the Connect button, another for the callback, a December calendar job with its own copy of the refresh logic, and a helper for calling the photo API. Each piece worked when it was written. Together they kept tokens in three places, refreshed them in two different ways, and read the photo service's token endpoint from a configuration file that nobody updated when the address changed.

Every client-side check in the earlier lessons has to live somewhere in real code. Where it lives decides whether it runs every time, including in the code someone adds next year.

Starting from a library

Much of a client's protocol work is precisely specified and easy to get slightly wrong. Generating a PKCE verifier and challenge, encoding an authorization URL, comparing the iss response parameter, form-encoding a client secret before HTTP Basic, signing a client assertion, and parsing an error response each have a correct answer, and each has caught out hand-written code. A well-maintained OAuth library has already met these problems, and its maintainers change it when security guidance changes.

Choosing one deserves some care. Look for active maintenance and a visible history of security fixes. Check that it supports what this integration needs: PKCE with S256, the iss response parameter, authorization server metadata, and the client authentication method in the printer's registration. Prefer a library whose defaults are already safe, so that PKCE is on unless someone deliberately turns it off and the implicit grant is not offered at all. For OpenID Connect and FAPI profiles, the OpenID Foundation certifies implementations that pass its conformance tests, and Interoperability and conformance testing described what such a result does and does not prove.

A library implements the protocol. It does not know what a printer account is, which session started an attempt, or what "connected" means to the printer's customers. The printing company therefore wraps the library in a small module of its own, and the rest of the application talks only to that module. Updating the library then means changing one module, and the team treats those updates like any other security fix.

Configuration from metadata

All domains, identifiers, and credentials in these examples are fictional. The printer's module starts from a handful of values:

issuer:        https://auth.photos.example
client_id:     photo-printer
redirect_uri:  https://printer.example/oauth/callback
scope:         photos.read
api_base:      https://api.photos.example

Endpoint addresses are missing on purpose. The module reads authorization_endpoint, token_endpoint, and revocation_endpoint from the photo service's metadata document at https://auth.photos.example/.well-known/oauth-authorization-server, after checking its issuer against the configured value. It handles that document the way Validating metadata and issuer identity described: it refreshes it on a schedule, keeps the last validated copy when a fetch fails, and raises an alert, rather than quietly changing its behavior, if a capability it relies on such as S256 disappears. When the photo service moves its token endpoint, nobody at the printing company has to notice.

What stays in the module's own configuration is everything the printer decides for itself: which issuer it trusts, which client it is, where its callback lives, what access it asks for, and which API may receive its tokens. That division is worth keeping strict. A value the client should decide, such as its scope, never comes from a document the server publishes, and a value the server publishes, such as its token endpoint, is never copied into a file where it can go stale.

The parts of a client

Inside the module, the printer separates five jobs:

PartIts job
Transaction storeCreates the pending transaction when you select Connect, and lets the callback claim it once, from the same session.
Callback handlerChecks the response against its transaction and expected issuer, exchanges the code, and saves the result.
Token storeKeeps each connection's tokens on the backend, encrypted, keyed by printer account and issuer.
Refresh coordinatorThe only code that refreshes. It allows one refresh per connection at a time and saves a replacement refresh token before anything uses the new access token.
API callerThe only code that attaches an access token to a request, and only to the configured photo API.

A shortened sketch shows how the last two fit together. The oauth object stands for the wrapped library, and the lock can be a database row lock or a distributed lock, depending on how many servers run the code:

async function currentAccessToken(connectionId: string): Promise<string> {
  return withConnectionLock(connectionId, async () => {
    const stored = await tokenStore.load(connectionId);
    if (!stored) throw new NeedsReconnection(connectionId);
    if (stored.accessTokenExpiresAt > Date.now() + 60_000) {
      return stored.accessToken;                      // good for at least a minute
    }
    const result = await oauth.refresh(stored.refreshToken);
    await tokenStore.replace(connectionId, result);   // save first, then use
    return result.accessToken;
  });
}

async function callPhotoApi(connectionId: string, path: string): Promise<Response> {
  const url = new URL(path, config.apiBase);
  if (url.origin !== new URL(config.apiBase).origin) {
    throw new Error('Refusing to send a photo API token to another host');
  }
  const token = await currentAccessToken(connectionId);
  return fetch(url, { headers: { Authorization: `Bearer ${token}` } });
}

The origin check writes a rule from Using access tokens into the one function that attaches tokens: a token goes only to the API it was issued for. It is needed because new URL treats a full address as replacing the base, so a path such as https://elsewhere.example/ would otherwise carry the token to another host. The photo picker and the December calendar job both call callPhotoApi, so both get the same refresh behavior and the same rule about where tokens may go. If two workers ask for the same connection at once, the second waits for the lock and then finds the fresh token the first one saved. When a refresh fails with invalid_grant, the coordinator marks the connection as needing reconnection, instead of letting each caller decide what that error means. A 401 from the API leads to one refresh and one retry, as Using access tokens described, and that logic belongs in the API caller for the same reason.

The callback handler follows the order the authorization code lessons taught in Correlating requests and responses and Errors and denied access: claim the transaction, compare the issuer, handle an error response, and only then exchange the code, sending it to the token endpoint taken from validated metadata. Keeping those steps in one handler makes their order visible and easy to test.

Environments and secrets

The printer's test website at https://test.printer.example runs the same code with a different configuration and its own registration at the photo service:

SettingProductionTest
Client IDphoto-printerphoto-printer-test
Redirect URIhttps://printer.example/oauth/callbackhttps://test.printer.example/oauth/callback
Client credentialFrom the production secrets storeFrom the test secrets store
Photo accountsCustomers' accountsAccounts created for testing

The issuer is the same in both, because the test site connects to the photo service's real authorization server with test accounts. Everything that identifies the printer differs. If the test site accidentally loaded the production client ID, its first authorization request would fail at the photo service, because the test site's redirect URI is not registered for photo-printer. Separate registrations turn that configuration mistake into an error instead of a leak.

The client credential is the one value here that must stay private. The module reads it at run time from a secrets store, not from the repository or a file built into a container image. It never appears in logs, in error messages, or in process arguments, which other users of a machine can often read. Each environment has its own credential, so a developer with access to test credentials holds nothing that works in production. When the credential needs replacing, the overlap from Rotating client credentials applies, and a registration that uses private_key_jwt removes the shared secret altogether.

None of this is visible to the person connecting a photo account. They see a Connect button, a familiar approval screen, and their photos. The structure behind that experience is what keeps it working after the photo service moves an endpoint, the printer adds a second server, or an access token expires halfway through a December night.

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 2The printer's test website starts by mistake with the production client ID, photo-printer, while still using its own redirect URI. What happens when someone selects Connect there?

QUESTION 2 OF 2Why does every part of the printer reach the photo API through one API caller and one refresh coordinator?

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