API authentication and JWT validation

JWT bearer authentication applies to browser/BFF flows and explicit service-audience workload contracts. It is not the CLI or public tenant-control-plane credential. Protected capability APIs exported through the public API edge must also accept FGAI-HMAC-SHA256 signed requests from static AKIA service-user credentials and temporary workforce ASIA STS credentials with a signed X-FGAI-Security-Token. Human bearer tokens, including platform-admin tokens, are never CLI credentials; platform administration remains private to the admin-console web app.

This page explains the JWT-bearing surfaces and how a receiving service validates their tokens. See CLI authentication for the signed public-API contract.

Authorization Code + PKCE flow

The canonical flow for first-party FlexGalaxy.AI apps and third-party integrations alike is Authorization Code with PKCE (S256).

  1. Your application generates a PKCE code_verifier (cryptographically random, 43-128 chars) and derives code_challenge = BASE64URL(SHA256(code_verifier)).

  2. The user agent is redirected to the realm authorization endpoint, https://auth.flexgalaxy.ai/auth/realms/<realm>/protocol/openid-connect/auth:

    ?response_type=code
      &client_id=<your-client-id>
      &redirect_uri=<your-registered-redirect-uri>
      &scope=openid+profile+email
      &state=<csrf-token>
      &code_challenge=<challenge>
      &code_challenge_method=S256
    
  3. The user authenticates with DotID (PassPort + MFA if required).

  4. DotID redirects back to your registered URI with ?code=<auth-code>&state=<csrf-token>. Verify state matches the one you sent.

  5. Your application exchanges the code for tokens at https://auth.flexgalaxy.ai/auth/realms/<realm>/protocol/openid-connect/token:

    POST <realm-token-endpoint>
    Content-Type: application/x-www-form-urlencoded
    
    grant_type=authorization_code
    &code=<auth-code>
    &redirect_uri=<same-uri-as-step-2>
    &client_id=<your-client-id>
    &client_secret=<secret>           # confidential clients only
    &code_verifier=<original-verifier>
    
  6. DotID returns access_token, id_token, and (if offline_access was requested) refresh_token. All three are JWTs.

The access_token is presented only to the browser/BFF or explicitly registered service-audience surface as Authorization: Bearer <token>. It is not pasted into or stored by the FGAI CLI. The id_token is for your own application to learn who the user is. The refresh_token is exchanged at the same /token endpoint with grant_type=refresh_token.

Realms

DotID uses three realm types. Each one issues its own JWTs from its own signing key.

Realm

Who lives there

Example issuer claim

flexgalaxy

Platform admins (FlexGalaxy operators), account-root users, and platform-level workforce

https://auth.flexgalaxy.ai/auth/realms/flexgalaxy

acc-<account-id>

Per-account service users

https://auth.flexgalaxy.ai/auth/realms/acc-029cea77800e

idc-<org-id>

Per-organization Identity Center personas (SSO)

https://auth.flexgalaxy.ai/auth/realms/idc-7b39dd74a2d9

An Organization has one idc-* realm for workforce identities. Account-owned service users use the Account boundary and are non-interactive; standalone Accounts do not receive a separate workforce directory. Root users reside in the platform root realm. These identity populations are isolated and are never merged by matching email.

Tenant binding: account_id and user_type claims

Access tokens issued by the flexgalaxy realm carry two claims that describe the authenticated subject’s tenant binding: account_id and user_type.

{
  "iss": "https://auth.flexgalaxy.ai/auth/realms/flexgalaxy",
  "sub": "9e0f6f96-2f57-4f49-84ef-8d4d12d4f5e1",
  "account_id": "11111111-1111-1111-1111-111111111111",
  "user_type": "root"
}
  • account_id — the DotID account UUID the subject is bound to. It is emitted for both account-root users and workforce members of an account. It means “this user belongs to this tenant” — nothing more. It is not the account name and not an acc-* realm name.

  • user_type — how the subject is bound to that account:

    • root — the account’s root user.

    • workforce — a workforce member of the account.

Rules:

  • Derive account-root status strictly from user_type == "root". Never infer root from the mere presence of account_id — a workforce user also carries account_id but is not a root. Treating “has account_id” as “is root” will silently promote every workforce member to root-equivalent.

  • Use account_id to resolve the caller’s tenant (for per-request scoping and when building PDP principal context). Do not derive account identity from email, username, or realm name.

  • Unbound flexgalaxy users (e.g. platform admins) receive neither claim. If account_id is absent, the caller is not bound to a tenant; if user_type is absent or is not root, do not treat the caller as an account root. Route the request through the appropriate platform-admin, workforce, service-user, or workload-identity path instead.

These claims are identity context, not an authorization grant. They tell a service which tenant the validated token belongs to and whether the subject is that account’s root; they do not say the caller may perform a particular service action. A first-party service must still call the DotID PDP for any account/resource operation that is policy controlled.

Validating JWTs your service receives

For a route whose manifest explicitly declares JWT bearer authentication, the platform convention is that the receiving service accepts the allowed realm types declared for that audience. Public tenant routes on the API edge additionally implement the signed AK/SK and STS path (temporary ASIA credentials, available to any principal via sts assume-role); JWT support on another audience does not satisfy that public contract.

Concretely, your JWT validator must:

  1. Extract the iss claim from the token header / payload.

  2. Verify the issuer URL is one of: https://auth.flexgalaxy.ai/auth/realms/flexgalaxy or https://auth.flexgalaxy.ai/auth/realms/acc-* or https://auth.flexgalaxy.ai/auth/realms/idc-*. Reject any other issuer.

  3. Fetch the realm’s JWKS from <issuer>/protocol/openid-connect/certs. Cache the keys (DotID rotates them on a schedule; respect Cache-Control headers).

  4. Verify the signature against the JWKS, matching on the token’s kid header.

  5. Verify the standard claims: exp (not expired), nbf (if present, not before), iat (sanity-check against clock skew), aud (matches your service’s audience identifier).

  6. Verify any service-specific claims your authorization logic depends on (e.g. realm_access.roles, groups).

DotID’s own services implement exactly this pattern, and two acceptable approaches exist:

  • Open acceptance — accept any realm whose JWKS endpoint resolves and serves valid keys.

  • Explicit allowlist — accept flexgalaxy plus a prefix match for acc-* / idc-*, rejecting anything else. Slightly more secure (rejects rogue realms even if they reach the auth server) at the cost of a config update when a new realm category is added. Some services choose this stricter allowlist form.

Pick one approach per service and document it. Mixing the two within one service leads to subtle authorization gaps.

AuthorizeRequest / AuthorizeResponse shape

Calling the DotID Policy Decision Point (PDP) from your service uses a fixed request/response contract.

The PDP call is the authorization step. JWT validation answers “is this token real, current, issued by DotID, and meant for this service?” The PDP answers “may this principal perform this action on this resource in this account?”

Do not skip PDP because a token has account_id. DotID centralizes policy evaluation so every first-party service observes the same behavior for managed policies, permission sets, permission boundaries, service control policies, delegated administration, resource policies, and root-user account ownership. If each service re-implements those checks locally, policy behavior will drift between services.

Minimal request:

{
  "principal": {
    "account_id": "11111111-1111-1111-1111-111111111111",
    "user_id": "u-12345",
    "user_type": "iam",
    "ic_session": false,
    "ps_id": null
  },
  "action": "licensing:Device:Read",
  "resource": "frn:11111111-1111-1111-1111-111111111111:licensing:device/dev-12345",
  "context": {
    "source_ip": "203.0.113.42",
    "request_time": "2026-05-25T11:00:00Z"
  }
}

Minimal response:

{
  "decision": "ALLOW",
  "reason": null,
  "matched_statement": "AllowDeviceRead",
  "evaluation_time_ms": 2
}

The decision is always ALLOW or DENY. The reason is null on allow and a stable machine-readable code on deny; matched_statement is the matching policy statement Sid, or null when no statement matched. DotID serializes Java record fields with snake_case JSON names.

Discovering this resource (RFC 9728)

Every 401 from the public API edge and from the console API surface names where to find this resource’s OAuth 2.0 protected-resource metadata:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://api.flexgalaxy.ai/.well-known/oauth-protected-resource"

Fetch that URL to get the RFC 9728 metadata document for the host you called:

curl -sS https://api.flexgalaxy.ai/.well-known/oauth-protected-resource
{
  "resource": "https://api.flexgalaxy.ai",
  "bearer_methods_supported": ["header"]
}

Those are the two fields a client needs; the document also carries the other RFC 9728 registry fields. The advertised URL is always https, always carries no port, and always names the host your request carried — a client may follow it as-is. The document is public: discovery precedes authentication, so it needs no credential.

FlexGalaxy.AI is multi-tenant and each account authenticates against its own realm, so the metadata document deliberately does not enumerate authorization servers. Resolve the realm for your account first (see OIDC client setup), then use that realm’s own /.well-known/openid-configuration.

Common errors

Status

Meaning

What to check

401 Unauthorized

The credential is absent or invalid.

On JWT-bearing browser/workload routes, verify the bearer token’s iss/kid; on public API-edge routes, verify the FGAI-HMAC signature and required STS session token.

403 Forbidden

The token was valid but the PDP returned DENY.

Inspect the response body — it includes the reason and (if available) matched_statement to trace the deny.

400 Bad Request (PDP)

Invalid principal object, action, or resource FRN.

See FRN format for the canonical resource syntax and parsers.

400 Bad Request (DotID API)

API versioning mismatch — the request was sent to a deprecated path.

Public developer APIs use /<capability>/v1/...; first-party console APIs use their gateway prefixes. Service-local implementation paths are not public unless an ADR lists an exact gateway exception.

Federated workload tokens

DotID also issues access tokens through RFC 8693 token exchange for workload identities federated from external OIDC issuers (GitHub Actions, GitLab CI). Validation at your service is identical to validating any other DotID-issued JWT — the same realm JWKS, the same iss / aud / exp rules. The only difference is the token’s sub is a DotID workload identity ID rather than a human or service-user ID, and the token carries an extra federated_subject custom claim of shape {issuer, sub} recording the originating CI subject for audit correlation.

Treat the federated_subject claim as audit metadata only — never use it for authorization decisions. The aud, sub, and scope claims are the authorization surface, just like every other DotID-issued token.

For the exchange contract (the endpoint your CI calls to obtain this token, not the validation your service does to consume it), see workload-federation.md.