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:
| Role | client_id prefix | Has secret? | Purpose |
|---|
| Public | app_… | 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.
| Variable | Value | Notes |
|---|
PYMTHOUSE_ISSUER_URL | https://your-pymthouse.example/api/v1/oidc | Must match iss in issued tokens. Use discovery to resolve endpoint paths. |
PMTHOUSE_CLIENT_ID | app_… | Public client id. Use everywhere the user or device sees a client_id. |
PMTHOUSE_M2M_CLIENT_ID | m2m_… | Confidential client id. Server-side only. Never expose to the browser or CLI. |
PMTHOUSE_M2M_CLIENT_SECRET | pmth_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:
- End-user token claims — which claims appear in tokens issued to the user.
- Programmatic user JWT requests — scopes requested when calling the user-token mint endpoint are validated against this list, not the M2M list.
- Billing pattern — see below.
| Scope | Effect |
|---|
sign:job | Grants access to sign a job. Default scope for user-token mint if none is specified. |
users:token | Marks 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.
| Scope | Endpoint |
|---|
users:read | GET /api/v1/apps/{clientId}/users |
users:write | POST, PUT, DELETE on /api/v1/apps/{clientId}/users |
users:token | POST /api/v1/apps/{clientId}/users/{externalUserId}/token; also required for RFC 8693 device completion |
device:approve | Alternative 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 |
|---|
| Yes | Per-user — usage is attributed to individual endUserId records. |
| No | App-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
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.
- 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.
- 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.
- 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.