Authentication
How to authenticate with Phoenix Pay using Zitadel machine-to-machine tokens.
Authentication
Phoenix Pay uses Zitadel for identity and access management. All API requests must include a valid JWT access token obtained via the OAuth 2.0 client credentials grant. The token's organization ID automatically determines your tenant scope.
Overview
┌──────────────┐ ┌──────────────┐
│ │ 1. Client Credentials │ │
│ Your App │─────────────────────────▶│ Zitadel │
│ │◀─────────────────────────│ │
│ │ 2. JWT Access Token │ │
└──────┬───────┘ └──────────────┘
│
│ 3. API Request
│ Authorization: Bearer <token>
▼
┌──────────────┐
│ │ 4. Validates JWT via JWKS
│ Phoenix Pay │ 5. Extracts org_id from claims
│ │ 6. Scopes all data to org_id
└──────────────┘Client Credentials Flow
Step 1: Create a Service Account
In the Zitadel console, create a service account (machine user) within your organization:
- Navigate to your organization in the Zitadel console
- Go to Users and select Service Users
- Create a new service user
- Under Keys or Client Credentials, generate a client ID and client secret
- Grant the service user the appropriate roles on the Phoenix Pay project
Step 2: Request an Access Token
Exchange your credentials for a JWT access token.
curl -X POST https://auth.phoenixverse.io/oauth/v2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=284762139458273649" \
-d "client_secret=your-client-secret" \
-d "scope=openid urn:zitadel:iam:org:project:id:PROJECT_ID:aud"{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjI0OTc...",
"token_type": "Bearer",
"expires_in": 43199
}The scope parameter must include urn:zitadel:iam:org:project:id:PROJECT_ID:aud where PROJECT_ID is the Phoenix Pay project ID. This ensures the token's audience claim matches what Phoenix Pay expects. Without it, your requests will be rejected with a 401.
Step 3: Use the Token
Include the token in the Authorization header of every API request.
curl https://pay.phoenixverse.io/api/deposits \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."Token Format
Phoenix Pay validates the JWT using Zitadel's JWKS (JSON Web Key Set) endpoint. The token contains these relevant claims:
{
"iss": "https://auth.phoenixverse.io",
"sub": "284762139458273649",
"aud": ["PROJECT_ID"],
"exp": 1741689600,
"urn:zitadel:iam:user:resourceowner:id": "293847561029384756",
"urn:zitadel:iam:user:resourceowner:name": "Acme Corp",
"urn:zitadel:iam:org:project:PROJECT_ID:roles": {
"tenant_admin": {
"293847561029384756": "Acme Corp"
}
}
}| Claim | Description |
|---|---|
iss | The Zitadel issuer URL. Must match Phoenix Pay's configured issuer. |
sub | The service account's user ID. |
aud | Must include the Phoenix Pay project ID. |
exp | Token expiration time (Unix timestamp). |
urn:zitadel:iam:user:resourceowner:id | The organization ID. This is used as the tenant identifier. All data is scoped to this org. |
urn:zitadel:iam:user:resourceowner:name | The organization's display name. |
urn:zitadel:iam:org:project:PROJECT_ID:roles | Roles granted on the Phoenix Pay project. |
Tenant Scoping
The resourceowner:id claim in your JWT determines your tenant. This mapping is automatic and cannot be overridden.
- All API calls are scoped to the org_id extracted from your token
- Deposits and payouts are only visible to the tenant that created them
- PSP configurations are isolated per tenant
- Webhook callbacks are sent to tenant-specific callback URLs
There is no X-Tenant-ID header or query parameter. Tenant isolation is enforced at the authentication layer through Zitadel's organization model.
Platform vs. Tenant Users
Phoenix Pay distinguishes between two types of users based on the token's organization:
| Type | Condition | Access |
|---|---|---|
| Platform User | org_id matches the configured Phoenix Games organization | Can access all tenants' data, manage tenant configurations, view cross-tenant statistics via /api/admin/* endpoints |
| Tenant User | org_id is any other organization | Can only access their own organization's payments and configuration |
Platform users are internal Phoenix Games operators. The platform organization ID is configured on the server and is not something tenants need to know about.
Roles
Phoenix Pay recognizes the following project roles:
| Role | Description |
|---|---|
platform_admin | Full access to all tenants and admin endpoints. Reserved for Phoenix Games operators. |
tenant_admin | Full access to the tenant's own payments, configuration, and PSP settings. |
Roles are extracted from the urn:zitadel:iam:org:project:PROJECT_ID:roles claim in the JWT.
Token Refresh
Access tokens are short-lived (typically 12 hours). Your application should handle token refresh.
class TokenManager {
constructor(clientId, clientSecret, issuerUrl, projectId) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.tokenUrl = `${issuerUrl}/oauth/v2/token`;
this.projectId = projectId;
this.token = null;
this.expiresAt = 0;
}
async getToken() {
// Refresh if token expires within 5 minutes
if (this.token && Date.now() / 1000 < this.expiresAt - 300) {
return this.token;
}
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.clientId,
client_secret: this.clientSecret,
scope: `openid urn:zitadel:iam:org:project:id:${this.projectId}:aud`,
});
const res = await fetch(this.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
});
const data = await res.json();
this.token = data.access_token;
this.expiresAt = Date.now() / 1000 + data.expires_in;
return this.token;
}
}import time
import requests
class TokenManager:
def __init__(self, client_id, client_secret, issuer_url, project_id):
self.client_id = client_id
self.client_secret = client_secret
self.token_url = f"{issuer_url}/oauth/v2/token"
self.project_id = project_id
self.token = None
self.expires_at = 0
def get_token(self):
# Refresh if token expires within 5 minutes
if self.token and time.time() < self.expires_at - 300:
return self.token
response = requests.post(self.token_url, data={
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": f"openid urn:zitadel:iam:org:project:id:{self.project_id}:aud",
})
data = response.json()
self.token = data["access_token"]
self.expires_at = time.time() + data["expires_in"]
return self.tokenError Responses
| Status | Meaning |
|---|---|
401 Unauthorized | Missing or invalid token. The token may be expired, have an invalid signature, or be missing the required audience claim. |
403 Forbidden | The token is valid but your role does not permit the requested action (e.g., tenant user accessing admin endpoints). |
{
"error": "unauthorized"
}Never expose your client secret in client-side code or public repositories. The client credentials grant is designed for server-to-server communication only.