Authentication¶
Invora uses OpenID Connect (OIDC) for authentication. Invora is its own identity provider (IdP), so every flow on this page is a standard OAuth 2.0 / OIDC grant against the Invora token endpoint.
Environments¶
Every value below is environment-specific — including the project-audience id.
| Production | Staging | |
|---|---|---|
| Token endpoint | https://auth.invora.app/oauth/v2/token |
https://stg-auth.invora.app/oauth/v2/token |
| Discovery document | auth.invora.app/.well-known/openid-configuration |
stg-auth.invora.app/.well-known/openid-configuration |
| API gateway | https://gateway.invora.app |
https://stg-gateway.invora.app |
| Project-audience id | 372376660185448530 |
372376692817133647 |
Swapping environments means swapping two values, not one
The project-audience id in your scope identifies the Invora project on a
specific issuer. Presenting the staging id to the production token endpoint
(or vice-versa) fails with invalid_client — the credential is not
resolvable there, so no token is ever issued.
When you move an example between environments, change the host and the project-audience id together, using the matching column above.
Examples on this page use the Production values; the Quickstart uses Staging.
Every API call carries the issued access token in the Authorization header as a
Bearer credential. For the full request anatomy (base URLs, content type, error
codes) see Getting Started; for an end-to-end walkthrough
see the Quickstart.
The access token is opaque — do not decode it
Invora issues reference (opaque) access tokens, not JWTs. An access token
is a random string that carries no readable claims. Do not parse or trust
its contents — call the introspection endpoint to
learn whether a token is active and what it grants. The id_token is the
only JWT Invora returns; use it to read user identity claims on the client.
Before you start¶
You authenticate with credentials issued by Invora. There are two ways to get them:
- Dashboard application — register an OIDC application at
dashboard.invora.app to obtain a
client_id,client_secret, and the set of allowed scopes. - Machine-to-machine credentials —
complete-profile(during registration) and tenant provisioning both return aclientId/clientSecretpair for server-side automation. See the Quickstart.
Choosing a flow¶
| Grant type | Use case | Credentials | Returns |
|---|---|---|---|
client_credentials |
Server-to-server / machine-to-machine | client_id + client_secret |
access_token |
authorization_code + PKCE |
Interactive user login (web, mobile, SPA) | client_id (+ PKCE) |
access_token, id_token, refresh_token |
password |
Direct username/password sign-in for users in your own tenant | client_id + client_secret + user creds |
access_token, id_token, refresh_token |
refresh_token |
Renew an expired access token without re-authenticating | client_id (+ client_secret) + refresh_token |
access_token, refresh_token |
All token requests are POSTs with Content-Type: application/x-www-form-urlencoded.
flowchart TD
subgraph M2M[Machine-to-machine]
A[client_id + client_secret] --> B[POST /oauth/v2/token<br/>grant_type=client_credentials]
B --> C[opaque access_token]
end
subgraph User[Interactive user]
D[Redirect to /oauth/v2/authorize<br/>+ PKCE challenge] --> E[User signs in]
E --> F[Authorization code]
F --> G[POST /oauth/v2/token<br/>grant_type=authorization_code]
G --> H[access_token + id_token + refresh_token]
end
C --> I[Call API:<br/>Authorization: Bearer]
H --> I
I --> J{401 / token expired?}
J -->|refresh_token| K[POST /oauth/v2/token<br/>grant_type=refresh_token]
K --> I
Authentication flows¶
Client credentials (machine-to-machine)¶
For server-to-server integrations and automation. No user is involved, so the
response contains an access token only — no id_token.
curl -s -X POST https://auth.invora.app/oauth/v2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
--data-urlencode "scope=openid urn:zitadel:iam:org:project:id:372376660185448530:aud urn:zitadel:iam:user:resourceowner"
Running this against staging
Swap both values: use https://stg-auth.invora.app/oauth/v2/token and the
staging project-audience id 372376692817133647 — see
Environments.
Both reserved scopes matter for machine-to-machine tokens: the project
audience scope is what makes the gateway accept the token at all, and the
urn:zitadel:iam:user:resourceowner scope embeds your home organization in
the token so requests are automatically scoped to your own tenant — without it
the API cannot resolve a tenant and returns PERMISSION_DENIED (HTTP 403) even
though the token is valid.
{
"access_token": "iIDF3v0p9X8q2nReferenceTokenOpaqueValue",
"token_type": "Bearer",
"expires_in": 43200
}
Keep the secret server-side
client_credentials grants broad access to the tenant. Store the
client_secret in a secret manager and never ship it to a browser or mobile
binary.
Authorization code + PKCE (user-facing apps)¶
For web, mobile, and single-page apps where a user logs in interactively. Public clients use PKCE (Proof Key for Code Exchange) and have no client secret.
- Generate a
code_verifierand itsS256code_challenge. -
Redirect the user to the authorization endpoint:
-
The user authenticates with Invora and is redirected back to
$REDIRECT_URI?code=.... -
Exchange the authorization code for tokens:
{
"access_token": "iIDF3v0p9X8q2nReferenceTokenOpaqueValue",
"id_token": "eyJhbGciOiJSUzI1NientersOnlyTheIdTokenIsAJWT...",
"refresh_token": "rfrL8s2K...rotates-on-every-refresh",
"token_type": "Bearer",
"expires_in": 43200
}
The id_token (a JWT) carries the user's identity claims for the client to read
— see ID token claims. The access_token stays opaque.
Resource owner password (password grant)¶
Direct username/password exchange. Select the user's organization with Zitadel's
reserved scope urn:zitadel:iam:org:id:{orgId} in the token request — Zitadel
enforces that the user is a member of that organization.
Same-tenant users only
The password grant can authenticate only users within your own tenant. It is not a way to act on behalf of another organization — for that, see Multi-organization access.
curl -s -X POST https://auth.invora.app/oauth/v2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "scope=openid profile offline_access urn:zitadel:iam:org:id:$ORG_ID" \
-d "username=$USERNAME" \
-d "password=$PASSWORD"
{
"access_token": "iIDF3v0p9X8q2nReferenceTokenOpaqueValue",
"id_token": "eyJhbGciOiJSUzI1NientersOnlyTheIdTokenIsAJWT...",
"refresh_token": "rfrL8s2K...rotates-on-every-refresh",
"token_type": "Bearer",
"expires_in": 43200
}
Refresh token¶
Renew an expired access token without re-authenticating. Request the
offline_access scope on the original flow to receive a refresh_token.
curl -s -X POST https://auth.invora.app/oauth/v2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "refresh_token=$REFRESH_TOKEN" \
-d "scope=openid profile offline_access"
This rotates both tokens — the response contains a new access_token and a
new refresh_token. Discard the old refresh token; reusing it fails with
invalid_grant.
Using the access token¶
Attach the access token to every API request as a Bearer credential:
curl -s -X POST https://gateway.invora.app/api/v1/simple/invoices/list \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
Over gRPC, send it as the authorization metadata entry (Bearer <token>).
Field naming and metadata details are covered in
gRPC & Transcoding. A missing or invalid token returns
UNAUTHENTICATED (HTTP 401); an insufficient scope returns PERMISSION_DENIED
(HTTP 403) — see Error Handling.
For the gateway to accept your token
The token must carry the Invora project audience. Dashboard-issued and
provisioned M2M credentials include it automatically. A custom OIDC
application must be granted the Invora project and request the audience scope
urn:zitadel:iam:org:project:id:<PROJECT_ID>:aud alongside openid, where
<PROJECT_ID> is the project-audience id of the environment you are calling
(372376660185448530 for production, 372376692817133647 for staging —
see Environments).
Machine-to-machine tokens (client_credentials) must additionally request
urn:zitadel:iam:user:resourceowner — it embeds the caller's home
organization, which is how requests without an explicit x-zitadel-orgid
header are scoped to the caller's own tenant.
Token introspection¶
Because the access token is opaque, use introspection (RFC 7662)
to validate it and read its metadata. Authenticate the introspection call with
the application's client_id / client_secret via HTTP Basic auth.
curl -s -X POST https://auth.invora.app/oauth/v2/introspect \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "token=$ACCESS_TOKEN" \
-d "token_type_hint=access_token"
{
"active": true,
"scope": "openid profile",
"client_id": "284732941234567891@invora",
"token_type": "Bearer",
"exp": 1750000000,
"iat": 1749956800,
"sub": "284732941234567890",
"aud": ["372376660185448530"],
"iss": "https://auth.invora.app",
"username": "user@acme.com",
"urn:zitadel:iam:user:resourceowner:id": "284732900000000000",
"urn:zitadel:iam:org:project:roles": {
"Invora.Documents.v2.List": { "284732900000000000": "acme.invora.app" }
}
}
An inactive, expired, or revoked token returns { "active": false } with no
other fields. Treat anything other than "active": true as unauthenticated.
ID token claims¶
The id_token is a JWT signed by Invora. Verify its signature against the
JWKS endpoint before trusting it, then read these claims:
| Claim | Description |
|---|---|
sub |
The user's unique ID |
urn:zitadel:iam:user:resourceowner:id |
The user's home organization (tenant) ID |
urn:zitadel:iam:org:project:roles |
Granted project roles, as a map of roleKey → { orgId: orgDomain } |
act |
Present only on impersonated tokens — the actor (administrator) behind the request |
iss, aud, exp, iat |
Standard OIDC issuer, audience, expiry, and issued-at |
The same identity data is available server-side from introspection, so backends never need to decode the JWT themselves.
Authorization scopes¶
Invora separates OAuth scopes (requested in the token call, e.g. openid,
profile, offline_access, the project audience) from authorization scopes
— the per-RPC permission identifiers the platform checks at runtime.
Every API method declares one authorization scope, named
Invora.<Module>.v<N>.<Operation>:
| Operation | Example scope |
|---|---|
| List | Invora.Simple.Invoices.v1.List |
| Get | Invora.Simple.Invoices.v1.Get |
| Create | Invora.Simple.Invoices.v1.Modify.Create |
| Freeze | Invora.Simple.Invoices.v1.Modify.Freeze |
| Admin — create tenant | Invora.Admin.Identity.v2.CreateTenant |
| Admin — impersonate | Invora.Admin.Impersonate |
At request time the platform evaluates the caller's Zitadel project roles
(from the urn:zitadel:iam:org:project:roles claim) against an authorization
rules engine to decide whether they hold the required scope. Because the mapping
of roles → scopes lives in editable rules, a platform super admin can change who
can call what without a code change or redeploy. Admin endpoints require
Invora.Admin.* scopes.
Multi-organization access¶
A user may have access to more than one organization (for example, a platform
operator managing its connected businesses). The access token still reflects the
user's home org; to act against a different org on a per-request basis, send
the target organization ID in the x-zitadel-orgid header:
curl -s -X POST https://gateway.invora.app/api/v1/simple/invoices/list \
-H "Authorization: Bearer $TOKEN" \
-H "x-zitadel-orgid: $TARGET_ORG_ID" \
-H "Content-Type: application/json" \
-d '{}'
The gateway validates the header against the organizations granted in the
caller's token — a user can only target an org they actually have a grant on —
then forwards the verified context to downstream services. No re-authentication
or token re-scope is needed to switch orgs. Over gRPC, send the same value as the
x-zitadel-orgid metadata entry.
For creating and managing connected businesses, and for the platform/reseller tenancy models, see Multi-Tenancy.
Personal access tokens (development only)¶
A machine user can mint a personal access token (PAT) in the Invora console. A PAT is an opaque Bearer credential you send directly — no token exchange step:
curl -s -X POST https://gateway.invora.app/api/v1/simple/invoices/list \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{}'
Development only
PATs are for local testing and quick debugging. Do not use them in production
integrations or automated systems — use the client_credentials flow with a
properly scoped machine user instead.
OIDC endpoints¶
| Endpoint | URL |
|---|---|
| Discovery | https://auth.invora.app/.well-known/openid-configuration |
| Authorization | https://auth.invora.app/oauth/v2/authorize |
| Token | https://auth.invora.app/oauth/v2/token |
| Introspection | https://auth.invora.app/oauth/v2/introspect |
| Userinfo | https://auth.invora.app/oidc/v1/userinfo |
| JWKS (signing keys) | https://auth.invora.app/oauth/v2/keys |
| End session | https://auth.invora.app/oidc/v1/end_session |
Best practices¶
- Never decode the access token. It is an opaque reference — validate via
introspection; read identity from the
id_tokenJWT. - Keep secrets server-side. Store
client_secretand refresh tokens in a secret manager; never embed them in browsers or mobile binaries. - Use PKCE for public clients. Web, mobile, and SPA apps must use
authorization_code+ PKCE, not the password grant. - Prefer short-lived access tokens and renew with
refresh_token. Handle rotation — each refresh invalidates the previous refresh token. - HTTPS only. Always call
auth.invora.appand the gateway over TLS.
Related¶
- Getting Started — environments, request/response format, protocol overview
- Quickstart — token to first invoice, end to end
- gRPC & Transcoding — gRPC metadata, camelCase JSON mapping
- Multi-Tenancy — connected businesses, org switching, platform models
- Error Handling —
UNAUTHENTICATED,PERMISSION_DENIED, and retry guidance