Skip to content

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 credentialscomplete-profile (during registration) and tenant provisioning both return a clientId / clientSecret pair 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.

Response
{
  "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.

  1. Generate a code_verifier and its S256 code_challenge.
  2. Redirect the user to the authorization endpoint:

    https://auth.invora.app/oauth/v2/authorize
      ?client_id=$CLIENT_ID
      &response_type=code
      &redirect_uri=$REDIRECT_URI
      &scope=openid%20profile%20email%20offline_access
      &code_challenge=$CODE_CHALLENGE
      &code_challenge_method=S256
    
  3. The user authenticates with Invora and is redirected back to $REDIRECT_URI?code=....

  4. Exchange the authorization code for tokens:

    curl -s -X POST https://auth.invora.app/oauth/v2/token \
      -H "Content-Type: application/x-www-form-urlencoded" \
      -d "grant_type=authorization_code" \
      -d "client_id=$CLIENT_ID" \
      -d "code=$AUTH_CODE" \
      -d "redirect_uri=$REDIRECT_URI" \
      -d "code_verifier=$CODE_VERIFIER"
    
Response
{
  "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"
Response
{
  "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"
Response
{
  "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_token JWT.
  • Keep secrets server-side. Store client_secret and 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.app and the gateway over TLS.