Skip to main content
PymtHouse uses a two-client model for every interactive developer app. Understanding the split between a public client and a confidential M2M client — and the scope table that governs both — is a prerequisite for correctly implementing any integration pattern.

The two-client model

Each developer app that needs both end-user and server-to-server access has two OIDC clients registered in PymtHouse:
Roleclient_id prefixHas secret?Purpose
Publicapp_…No (token_endpoint_auth_method: none)Device and browser flows; appears in user-facing URLs and verification links; client_id value in minted user JWTs
Confidential (M2M)m2m_…Yes (pmth_cs_…)Builder API calls (user provisioning, user JWT minting); RFC 8693 device completion token exchange
The two clients are siblings: developer_apps.oidc_client_id → public row; developer_apps.m2m_oidc_client_id → M2M row.
Never add a secret to the public client. Doing so would break device login because the token endpoint would then require the secret for every device poll request initiated by a CLI or SDK.

Why two clients instead of one?

OAuth 2.0 draws a hard line between public clients (those that cannot keep a secret, such as native apps and CLIs) and confidential clients (servers that can). A single client cannot satisfy both security requirements:
  • Device flow polling happens in a CLI or SDK — no place to store a secret.
  • Builder API calls must be authenticated to enforce the tenant boundary — a secret is required.
Separating them also means that rotating the M2M secret never affects active device sessions, and that a compromised public client_id does not expose any server-side credential.

Environment variables

Follow this naming convention when configuring your integration. Mistakes here are a common source of 400 invalid_scope and 401 errors.
VariableValueNotes
PYMTHOUSE_ISSUER_URLhttps://your-pymthouse.example/api/v1/oidcMust match iss in issued tokens. Use discovery to resolve endpoint paths.
PMTHOUSE_CLIENT_IDapp_…Public client id. Use everywhere the user or device sees a client_id.
PMTHOUSE_M2M_CLIENT_IDm2m_…Confidential client id. Server-side only. Never expose to the browser or CLI.
PMTHOUSE_M2M_CLIENT_SECRETpmth_cs_…Confidential secret. Server-side only. Rotate via the credentials endpoint.

OAuth scopes

Scopes control what each client can request and what claims appear in issued tokens.

Public client scopes (allowed_scopes)

These scopes are configured on the public app_… client. They govern:
  1. End-user token claims — which claims appear in tokens issued to the user.
  2. Programmatic user JWT requests — scopes requested when calling the user-token mint endpoint are validated against this list, not the M2M list.
  3. Billing pattern — see below.
ScopeEffect
sign:jobGrants access to sign a job. Default scope for user-token mint if none is specified.
users:tokenMarks the app as per-user billing mode (see below). Also required on the M2M client for minting operations.

M2M client scopes (allowed_scopes)

These scopes gate server-side calls from the confidential m2m_… client.
ScopeEndpoint
users:readGET /api/v1/apps/{clientId}/users
users:writePOST, PUT, DELETE on /api/v1/apps/{clientId}/users
users:tokenPOST /api/v1/apps/{clientId}/users/{externalUserId}/token; also required for RFC 8693 device completion
device:approveAlternative to users:token for RFC 8693 device completion only
Grant only the minimum scopes the server-side flow requires. users:read and users:write are independent; a backend that only provisions users but never lists them should not have users:read.

Billing pattern

The presence of users:token in the public client’s allowed_scopes determines the billing mode for the app:
Public client has users:token?Mode
YesPer-user — usage is attributed to individual endUserId records.
NoApp-level — usage rolls up to the app without user attribution.
This is derived from src/lib/allowed-scopes.ts → billingPatternFromAllowedScopesString. Add users:token to the public client only if your use case requires per-user billing data from the Usage API.

Client registration

Clients are created and managed through the developer dashboard or API. npm run oidc:seed initialises signing keys only — it does not create application clients. Ask the platform administrator if you need a client pair provisioned in a shared environment.

Key design decisions

  1. client_id as the URL tenant identifier. All Builder API paths use /api/v1/apps/{clientId}/… where clientId is the public app_… value. This avoids exposing internal database IDs and keeps the URL stable across secret rotations.
  2. Secrets on M2M only. Placing the secret exclusively on the M2M client allows the public client to be freely embedded in CLI binaries, native apps, and JavaScript SDKs without creating a secret-exposure risk.
  3. Scope policy on the public client governs JWT claims. Validating the requested scope for user-JWT minting against the public client — rather than the M2M client — means users cannot receive capabilities that exceed what the app registered for, regardless of which server-side credential makes the request.
  4. Billing pattern inferred from scope. Using the presence of users:token in allowed_scopes rather than a separate flag means the billing mode is a direct consequence of the integration pattern rather than an independent setting that can drift out of sync.

Implementation tasks

  • Register your app and both clients (public + M2M) before testing any endpoint.
  • Store PMTHOUSE_M2M_CLIENT_ID and PMTHOUSE_M2M_CLIENT_SECRET in your backend secret manager; never expose them to the browser, mobile app, or CLI.
  • Confirm that PMTHOUSE_CLIENT_ID is the app_… public id wherever it appears in device or browser flows.
  • Add only the scopes listed in the table above to each client; remove any default placeholder scopes added by tooling.
  • Rotate M2M secrets through the credentials endpoint; update the secret in your secret manager without touching the public client.