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

OAUTH 2.0 · LAB

Run a FAPI 2.0 payment flow, and gap-check your tenant today

Plan the lesson's Pay by bank flow end to end in FAPI mode with private_key_jwt and DPoP. Today, compare your tenant's discovery document with each FAPI 2.0 requirement and fix what configuration can fix.

PlannedUses your lab tenant

The lesson

Builds on: The purpose of a security profile.

New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.

Planned. The core of this lab waits on platform features that are not built yet. The planned walkthrough shows exactly how it will run; Do today is a real exercise you can do now.

Request console

Requests in this lab can be sent from this page to your tenant: open one and choose Send. Fill in the values below first. They stay in this page's memory and are gone when you leave; secrets are never stored or sent anywhere except the request you send.

Setup

  1. Use the shell variables from the earlier labs, and write down the current Flow policy, Token Decoder state and ID token manager key so you can restore them.

  2. Planned (G58): turn on FAPI 2.0 profile mode for the tenant (Security Profile, Message Signing off).

  3. Planned (G8, G14, G56): create a temporary client lab-tmp-pay-printer: confidential, private_key_jwt with the registered key printer-2026-10 from the JAR labs, DPoP-bound access tokens required, redirect URI http://127.0.0.1:8765/callback, scope prints.create.

  4. Planned (G3): the sample photo API offers a DPoP-protected payments resource at $ISSUER/resource/payments.

Note: the lab toolkit has no command yet that signs client assertions or DPoP proofs with your own key. The planned steps say exactly what each one contains; they need that command before they can run.

Planned walkthrough

  1. Configuration. Fetch the metadata and check that issuer equals $ISSUER exactly before using any endpoint in it.

GET$ISSUER/.well-known/oauth-authorization-server Open in console
GET $ISSUER/.well-known/oauth-authorization-server

Why it matters: every endpoint below comes from a document whose issuer you checked against your own configuration.

  1. Push. Build a client assertion with aud set to $ISSUER as a single string, and a DPoP proof for POST $ISSUER/oauth/par. Push response_type=code, client_id, redirect_uri, scope=prints.create (or, with G15, authorization_details describing a 42.50 EUR payment_initiation), code_challenge, code_challenge_method=S256 and dpop_jkt. Expect 201 with expires_in under 600.

Why it matters: PAR, client authentication, PKCE and code binding happen in one request, before the person sees anything.

  1. Visit. Open $ISSUER/oauth/authorize?client_id=<id>&request_uri=<encoded reference> and approve as Ava.

Why it matters: the browser carries a reference and nothing anyone could edit.

  1. Return. Compare iss with $ISSUER before anything else.

Why it matters: the mix-up defense is mandatory, and it comes first.

  1. Exchange. Within 60 seconds, redeem the code with a new assertion, the code_verifier and a new DPoP proof from the same key. Expect token_type: DPoP and no rotated refresh token.

Why it matters: "Where FAPI departs from habit". The code is short-lived and single use, the token is bound, and refresh tokens are not rotated.

  1. Pay. Call the payments resource with Authorization: DPoP and a proof that includes ath. The resource checks the binding before acting.

  2. For each step, write which row of the lesson's "Where each requirement comes from" table it satisfied.

Do today

  1. Pull the fields that matter for FAPI 2.0 from your tenant's discovery document.

curl -s "$ISSUER/.well-known/oauth-authorization-server" | jq '{
  issuer, par_endpoint: (.pushed_authorization_request_endpoint // "MISSING"),
  par_required: (.require_pushed_authorization_requests // false),
  response_types_supported, code_challenge_methods_supported,
  iss_param: .authorization_response_iss_parameter_supported,
  client_auth: .token_endpoint_auth_methods_supported,
  dpop_algs: (.dpop_signing_alg_values_supported // "MISSING"),
  mtls_bound: (.tls_client_certificate_bound_access_tokens // false),
  at_algs: .access_token_signing_alg_values_supported,
  par_status: .btl_endpoint_status["/oauth/par"]}'
curl -s "$ISSUER/.well-known/openid-configuration" | jq .id_token_signing_alg_values_supported
  1. Fill in this table. The middle column shows what a new tenant reports.

FAPI 2.0 requirementYour tenantFixable by configuration?
PAR for every requestMISSING, not_implementedNo (G11)
PKCE with S256["S256"]Already met; keep PKCE required on every client
response_type=code onlyDepends on Flow policyYes, in Flow policy
iss in every responsetrueAlready met
Codes single use, at most 60 secondsSingle use always; lifetime set in Flow policyYes, set 60 seconds
Client authentication by mutual TLS or private_key_jwt["client_secret_basic","none"]No (G8)
Sender-constrained tokensMISSING, falseNo (G14 or G8)
Confidential clients onlyThe Token Decoder is publicYes, disable it and create no public clients
PS256, ES256 or EdDSAAccess tokens ES256; ID tokens RS256 by defaultPartly: point the ID token manager at an ES256 key
No refresh token rotationRotation always onNo (G58)
Tokens never in a URL queryUserInfo refuses query tokensAlready met
TLS 1.2 or laterRecorded belowPlatform setting, not a tenant one
  1. Confirm two rows by request. Tokens in a query string are refused:

GET$ISSUER/oidc/userinfo?access_token=x Open in console
GET $ISSUER/oidc/userinfo?access_token=x

The answer is 400. Then record the TLS row: curl --tls-max 1.1 -sv "$ISSUER/.well-known/openid-configuration" -o /dev/null fails to connect.

  1. Apply every "Yes" row, as in Narrow your tenant to a written security profile, run step 1 again and mark what changed. The "No" rows are this lab's missing infrastructure.

Restore: put back the Flow policy, Token Decoder and ID token manager values you wrote down in Setup.

Break it

These run once the gaps close. Each is a request an honest FAPI client never sends.

  1. Open an ordinary authorization URL for lab-tmp-pay-printer: refused with invalid_request, because PAR is required.

  2. Wait 70 seconds before the exchange: invalid_grant, the code expired.

  3. Send a client assertion whose aud is the token endpoint address instead of the issuer: 401 invalid_client.

  4. Exchange the code with a proof from a different key of yours: invalid_grant, because the code is bound to the original key.

Check your work

Today, your table is complete, Audit shows tenant.oauth.policy.update and tenant.oauth.clients.update for the "Yes" rows and their restore, and Logs show oidc.userinfo rejected invalid_request for the query token. Once the gaps close, Audit shows oauth.par succeeded, oauth.authorize code_issued, oauth.token succeeded with private_key_jwt and DPoP, the resource call, and each Break it refusal with its own reason.

Cleanup

  1. Confirm the Flow policy, Token Decoder and ID token manager are restored.

  2. Once the gaps close, turn FAPI profile mode off and delete lab-tmp-pay-printer and its key.

Missing infrastructure

  • G11 (PAR), G14 (DPoP with dpop_jkt), G8 (private_key_jwt) and G56 (client keys): the mechanisms the flow combines.

  • G58: FAPI profile mode that enforces the whole profile and stops refresh token rotation.

  • G3: a sample payments resource that checks DPoP binding.

  • G15 (optional): authorization_details for the payment_initiation request.

  • Once these exist and the toolkit can sign assertions and proofs, the Planned walkthrough runs as written.

Back to all labs

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

The Lab