Skip to main content

Documentation

Auth & the five gates

How a request is authenticated and authorized — where the bearer comes from, the four route tiers and JWT audiences, and the five composable gates.

The request path in one picture

Every protected call into the SynaptaGrid Platform passes through three layers before your handler runs:

  1. Identity (OIDC). A bearer token minted by AuthN — a standards-compliant OIDC provider, the Auth0/Okta model run per deployment — verified against a JWKS keyset with mandatory audience and issuer checks.
  2. Route tier. One of four prefixes — public, portal, cp, internal — each with exactly one authentication model. No route ever accepts two.
  3. Five gates. Composable FastAPI dependencies that check, in order: are you authenticated, are you permitted, is the subscription active, is the feature in the plan, are you under quota.

None of this is reimplemented per service. Authentication comes from egav-auth-client, permission from egav-authz-client, and the subscription/feature/quota gates from egav-cp-client. A service wires them once and composes them per route. Every gate fails closed: if AuthZ, the Control Plane, or Redis is unreachable, the request is refused with 503, never waved through.

OIDC tokens: RS256, JWKS, audience + issuer

Bearer tokens are AuthN-issued RS256 JWTs. Each service verifies them with PyJWKClient against AuthN's JWKS endpoint, and — per RFC 9068 §4 — audience and issuer are always verified. If either the expected audience or the expected issuer is unconfigured, decode() raises rather than accepting the token; there is no unverified fast path for user-supplied tokens.

The JWKS URL resolves from AUTHN_JWKS_URL, or is derived from AUTHN_API_URL by appending /.well-known/jwks.json. (An earlier fallback that read the JWKS URI from Control Plane config was removed when that endpoint retired.)

The verify call is deliberately boring — the security lives in the mandatory options, not in clever code:

jwt_decoder.pypython
signing_key = jwks_client.get_signing_key_from_jwt(token)

return pyjwt.decode(
    token,
    signing_key.key,
    algorithms=["RS256"],
    audience=expected_aud,      # tier-suffixed, e.g. "<base>:cp" / "<base>:portal"
    issuer=expected_iss,
    options={"verify_aud": True, "verify_iss": True},   # RFC 9068 §4
)

Where the bearer comes from

The token the gates verify is minted by AuthN, a standards-compliant OIDC provider — not a thin proxy in front of a third-party IdP. It exposes the ordinary OIDC surface: authorization-code authorize / token endpoints, userinfo, a discovery document (/.well-known/openid-configuration) and the JWKS keyset (/.well-known/jwks.json). Any conformant OAuth2/OIDC client library can drive it, and users authenticate through your UI — they never see an IdP login page.

Three sign-in methods land on the same kind of token:

MethodWhere the client startsWhat happens
Email + passwordPOST /v1/authn/public/oidc/headless/credentials on a session opened with POST /v1/authn/public/oidc/authorize/headlessAuthN authenticates server-side on that session and returns an authorization code — or mfa_required, to complete with POST .../headless/mfa. The code is redeemed at /token with the PKCE verifier. The portal and CP tiers are separate (/public/oidc/... issues portal tokens, /cp/oidc/... CP tokens). There is no direct token grant: the old POST /public/login/credentials is retired.
Social (Google, GitHub)POST /v1/authn/public/oidc/headless/social with {session_id, provider}Returns an authorization_url pointing straight at the provider; AuthN's callback finishes the session's authorization code, redeemed at /token. The session-less GET /{tier}/login/{provider} start is retired.
Enterprise SSO (SAML / OIDC)GET /v1/authn/public/oidc/sso/start?org=…&oidc_session_id=…Per-org SSO on an open sign-in session — the tenant is chosen by org (and provider when an org has more than one method); redirects to that org's own IdP, and the callback finishes the session's authorization code, redeemed at /token like any other sign-in.

Your app's frontend uses its own OIDC client. An operator registers it in the Control Plane console (Sign-in & SSO → OIDC clients), or with POST /v1/control-plane/cp/oidc-clients:

oidc-client.jsonjson
{
  "client_name": "Inspections web",
  "client_type": "public",
  "context": "public",
  "redirect_uris": ["https://app.example.com/callback"]
}
  • context decides who can sign in with the client, and it is required. Use "public" for an app whose customers sign in from any workspace; the sign-in then names the workspace (below). "cp" is for operator tools: customers can't sign in through it. "org" binds the client to the one workspace in org_guid. Any other value, or none, is refused with 422. A customer who tries to sign in through a "cp" client is told the app isn't set up for customer sign-in.
  • A browser client is public with PKCE and no secret. A confidential client's secret is shown once, at creation.
  • The first time a user signs in to a client that isn't marked verified, /headless/credentials answers consent_required instead of a code. Show the user the app and scopes it returns, then send POST /v1/authn/public/oidc/headless/consent with {session_id, decision: "allow"} to get the code.
  • The returned client_id is what you send on /authorize/headless.

A customer sign-in names its workspace. Accounts belong to one organization, so a portal session must be bound to one before any credential is checked. Send org_guid (what a workspace's own portal knows) or workspace (the slug the user typed) in the /public/oidc/authorize/headless body, unless your client is already bound to one org. Without it, a correct password is answered 401 Invalid email or password:

authorize-headless.jsonjson
{
  "client_id": "<your client id>",
  "redirect_uri": "<a redirect URI registered for that client>",
  "response_type": "code",
  "code_challenge": "<S256 challenge>",
  "code_challenge_method": "S256",
  "org_guid": "<the workspace's organization guid>"
}

Browser and mobile clients use authorization-code with PKCE — code_challenge (S256) is mandatory on /authorize for public clients, and the code is redeemed together with its code_verifier at /token. Programmatic clients skip the browser with the headless pair (/authorize/headless + /headless/credentials); this is the 3-step flow the Go / Rust / PHP / Node SDKs wrap in OIDCHeadlessTokenManager (see SDKs & clients).

Short access tokens, single-use refresh

A successful login returns two tokens: a short-lived access JWT (15 minutes by default) — the bearer you attach to every request — and a refresh token that renews it without re-prompting the user. When the access token expires, exchange the refresh token at POST /v1/authn/{portal|cp}/refresh-token (the refresh token is the credential, so no bearer is sent) for a fresh pair.

Refresh is single-use: each rotation mints a new access + refresh pair and atomically revokes the spent refresh JTI. Every refresh token also carries a family id, so if an already-rotated (i.e. stolen) refresh token is replayed, AuthN revokes the entire family and forces re-authentication — OWASP refresh-token reuse detection. The payoff of short access lifetimes: a leaked access token stops working within its window, and a leaked refresh token stops working on its next use.

Automating Control Plane administration: admin service accounts

A script or pipeline that administers the Control Plane signs in as an admin service account, never with a person's sign-in. An operator creates the account under Operator Access → Service Accounts, gives it an operator role, and issues an API key. The key is shown once; store it in your secret manager.

Exchange the key for a Control Plane access token with the OAuth 2.0 client credentials grant (RFC 6749 §4.4). Send the key as client_secret in the form body, with no client_id and no HTTP Basic header:

bash
curl -s -X POST "$AUTHN_URL/v1/authn/cp/oidc/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=client_credentials" \
  --data-urlencode "client_secret=$CP_SERVICE_ACCOUNT_KEY"

The answer is {"access_token": "...", "token_type": "Bearer", "expires_in": 900}. Send the token as Authorization: Bearer <access_token> to /v1/control-plane/cp/... routes. When it expires, exchange the key again; there is no refresh token.

What to expect:

  • Permissions come from the role. The account can do exactly what its operator role allows, decided by the same check as for a person with that role. A route the role does not grant answers 403.
  • Every key expires. The longest lifetime is a platform setting (90 days unless changed). An access token lasts at most 15 minutes and never past its key's expiry.
  • Revoking a key or disabling the account takes effect on the next request, including for access tokens already issued.
  • Rotation issues a new secret for the same account and role. The old key keeps working for a short overlap so you can deploy the new one, then stops.
  • Errors: 401 invalid_client means the key is unknown, revoked or expired, or the account is disabled (the answer is the same for each, on purpose). 503 temporarily_unavailable means the key could not be verified right now; retry with backoff, nothing was issued. A client_id or HTTP Basic header gets 400 invalid_request.

An admin service account is not a workspace API key. Workspace keys are exchanged at /v1/authn/public/oidc/token for a portal token and act inside one workspace; they cannot call Control Plane routes. It is also not a Google service account and not a credential for service-to-service calls between platform services.

Calling a workspace's API as a service account

A script, pipeline or integration that works inside one customer workspace uses a workspace service account: a named, non-human member of that organization. A workspace admin creates it in Portal under Organization → Service accounts, gives it a role, adds it to groups and applications like any member, and creates a key with an expiry. The secret starts with sgwsa_ and is shown once; store it in your secret manager. The sgwsa_ prefix also lets secret scanners recognize a leaked key.

Exchange the key for a portal access token with the OAuth 2.0 client credentials grant (RFC 6749 §4.4) on the public token endpoint. Send the key as client_secret in the form body, with no client_id and no HTTP Basic header:

bash
curl -s -X POST "$AUTHN_URL/v1/authn/public/oidc/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=client_credentials" \
  --data-urlencode "client_secret=$WORKSPACE_SERVICE_ACCOUNT_KEY"

The answer is {"access_token": "...", "token_type": "Bearer", "expires_in": 900}. Send the token as Authorization: Bearer <access_token> to the workspace's portal routes, exactly as a signed-in member's token. When it expires, exchange the key again; there is no refresh token.

bash
TOKEN=$(curl -s -X POST "$AUTHN_URL/v1/authn/public/oidc/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=client_credentials" \
  --data-urlencode "client_secret=$WORKSPACE_SERVICE_ACCOUNT_KEY" | jq -r .access_token)

# Any portal route the account's role allows; this one lists the workspace's service accounts.
curl -s "$API_URL/v1/control-plane/portal/service-accounts" \
  -H "Authorization: Bearer $TOKEN"

What to expect:

  • The token acts as the service account. Its subject is the account itself, and the organization comes from the account's own record, never from the request. Every permission check treats it as the member it is: it can do exactly what its role, groups and application access allow, and a route they do not grant answers 403.
  • The account cannot sign in any other way. It has no password, no single sign-on identity and no invitation, and it cannot hold the owner role.
  • Lifetimes. Every key has an expiry the admin chose when creating it. An access token lasts at most 15 minutes and never past its key's expiry.
  • Rotation issues a new secret for the same account. The previous key keeps working for a short overlap so you can deploy the new secret, then stops.
  • Revoking a key, disabling the account or deleting it stops the key, and access tokens already issued from it stop working too.
  • Audit. Every change the token makes is recorded under the service account's name, with the id of the key that was used.
  • Errors: 401 invalid_client means the key is unknown, revoked or expired, or the account is disabled (the answer is the same for each, on purpose). 403 means the key is valid but its workspace is not active. 503 temporarily_unavailable means the key could not be verified right now; retry with backoff, nothing was issued. A client_id or HTTP Basic header gets 400 invalid_request.

Workspace API keys are exchanged at the same endpoint with the same request. Choose a service account when the work needs its own identity, its own role and an audit trail under its own name.

The four route tiers

Routes are separated by trust tier, and each tier has one — and only one — authentication dependency. Mixing two auth models on a single route ("Bearer or x-api-key") is prohibited; a service-to-service caller that needs data from a portal route gets its own /internal/* endpoint instead.

TierPrefixAuth dependencyWho calls it
Public/v1/{service}/public/*nonehealth, OIDC discovery, JWKS, login init
Portal/v1/{service}/portal/*validate_portal_bearertenant users, via the app frontend
CP/v1/{service}/cp/*validate_bearer_cpControl Plane operators
Internal/v1/{service}/internal/*validate_x_api_keyother platform services

All four dependencies are wired from egav-auth-client via a single init_auth_dependencies() call at startup, then re-exported by each service. The wrapper writes the resolved identity onto request.state.identity, so downstream gates can read org_guid without every route re-threading the identity object:

app/middlewares/authentication.pypython
from egav_auth_client import init_auth_dependencies
from fastapi import Depends, Request

_auth = init_auth_dependencies()

_validate_bearer_cp = _auth.validate_bearer_cp
_validate_portal_bearer = _auth.validate_portal_bearer
_validate_x_api_key = _auth.validate_x_api_key


async def validate_portal_bearer(request: Request, identity=Depends(_validate_portal_bearer)):
    request.state.identity = identity   # org_guid / user_guid available to every gate downstream
    return identity

What a caller puts on the wire

Each tier's dependency reads exactly one credential, and a caller sends only the one its target route expects — never two on the same request (a route that accepted "Bearer or x-api-key" would merge two trust tiers).

HeaderValueTierNotes
Authorization: Bearer <jwt>the access token from AuthNportal, cpThe tier is enforced by the JWT audience, not the header — a CP token on a portal route is rejected at the JWKS/RS256 layer. Because the token rides in a header, not a cookie, these requests aren't exposed to CSRF the way a cookie session would be.
x-api-key: <key>the caller's own keyinternalEach calling app owns one key and presents that same key to every service it calls. Each callee lists the callers it admits in its SERVICE_API_KEYS registry, under the caller's app code, so every internal call is attributed to a named caller. There is no shared inbound key and no per-pair key. An unregistered or unset key fails closed to 401. In a platform deployment the key is provisioned for you from your manifest (spec.required_env entry with source: operator, source_field: platform_caller_api_key, secret: true) and registered on the services you call; on a local stack an operator adds <your-app-code>:<key> to each callee's SERVICE_API_KEYS and restarts it.
X-App-Instance-Guid: <guid>the tenant app instancetenant data routesSelects the per-request tenant database and scopes AuthZ role grants to that instance, so a role granted on one instance can't authorise a request against another. It is a routing hint, not a grant — ownership is still verified server-side (the BOLA check below). If the URL also carries an instance GUID and the two disagree, the request is rejected 400; the URL wins.

Tiers are isolated at the crypto layer, not just by a check

The portal and cp tiers aren't kept apart by a post-decode if alone — they're separated inside the JWT verification itself. A single base audience is configured in Control Plane; AuthN mints each token with aud set to "<base>:<tier>", and each tier gate decodes against its own suffix. A portal token presented to a CP route fails pyjwt's audience check and is rejected as an invalid token before any database lookup or context check runs. The post-decode context assertion is kept only as defense in depth.

Revocation is checked after signature and audience pass — a revoked-but-otherwise-valid token is rejected, and if Redis is unavailable during that check the gate returns 503 rather than silently allowing.

dependencies.pypython
async def validate_bearer_cp(self, request: Request) -> TokenClaims:
    # Decodes against "<base>:cp" — a portal token is rejected at the JWT layer (401).
    claims = await self.validate_bearer_jwt(
        request, expected_audience=self._tier_audience("cp"),
    )
    if claims.context != "cp":                      # defense in depth
        raise HTTPException(status_code=403,
            detail="This endpoint requires Control Plane operator access")
    return claims

async def validate_portal_bearer(self, request: Request) -> PortalIdentity:
    claims = await self.validate_bearer_jwt(
        request, expected_audience=self._tier_audience("portal"),
    )
    if claims.context == "cp":
        raise HTTPException(status_code=403,
            detail="CP tokens cannot access portal routes")
    if not claims.org_guid:
        raise HTTPException(status_code=403, detail="Token missing org_guid")
    return PortalIdentity(user_guid=claims.user_guid, org_guid=claims.org_guid, role=claims.role)

The five gates

On top of authentication, a route composes entitlement gates as ordinary FastAPI dependencies. Each has a single responsibility and a single failure status:

#GateImportChecksOn failureOn dependency down
1Authegav-auth-clientvalid, unrevoked, right-tier token401503 (revocation via Redis)
2PermissionRequireAuthz (egav-authz-client)user may perform this action on this resource403503 (AuthZ)
3Subscriptionrequire_active_subscription (egav_cp_client.feature_gates)org's subscription is active402503 (CP)
4Featurerequire_feature (egav_cp_client.feature_gates)feature is enabled in the org's plan403503 (CP)
5Quotarequire_quota (egav_cp_client.feature_gates)usage + cost is within the plan limit429503 (CP)

RequireAuthz is a class-based dependency invoked as RequireAuthz(resource_type, action); when action is omitted it's derived from the HTTP method (GET→read, POST→create, PUT→update, DELETE→delete). A super_admin role bypasses the AuthZ network call, and the client is circuit-breaker protected so a struggling AuthZ fast-fails to 503 instead of hanging every request.

Two gate surfaces, one model. The table above is the FastAPI-dependency form used by brownfield services. egav_app_sdk also ships thinner decorators — requires_permission, requires_feature, enforce_quota, plus get_app_context — for apps built on the SDK's app runtime. Same five concerns; different call style.

A protected route, end to end

Here is the reference: five gates declared once on a portal route. Because RequireAuthz both authenticates the bearer and checks the permission, gates 1 and 2 collapse into a single dependency — so the Python route lists four dependency objects, not five. One cached entitlements fetch from the Control Plane then powers the subscription, feature, and quota gates.

protected_route.pypython
from fastapi import APIRouter, Depends, Request
from egav_authz_client import RequireAuthz
from egav_cp_client.feature_gates import (
    require_active_subscription,
    require_feature,
    require_quota,
)

router = APIRouter()

async def project_count(request: Request) -> int:
    return await count_projects(request.state.identity.org_guid)   # usage probe

@router.post(
    "/v1/my-saas/portal/projects",
    dependencies=[
        Depends(RequireAuthz("project", "create")),   # 1 auth + 2 permission -> 401 / 403
        Depends(require_active_subscription),          # 3 subscription     -> 402 when lapsed
        Depends(require_feature("projects")),          # 4 feature          -> 403 when not in plan
        Depends(require_quota("max_projects", current_usage=project_count)),  # 5 quota -> 429
    ],
)
async def create_project(payload: ProjectCreate):
    # Every gate passed. Any failure already returned a typed status in the
    # platform's standard error envelope; the handler only runs on success.
    return await save_project(payload)
feature_gates.pypython
async def require_active_subscription(request: Request) -> dict[str, Any]:
    org_guid = _extract_org_guid(request)
    if not org_guid:
        raise HTTPException(status_code=403, detail={...})   # missing org context -> fail closed
    payload = await get_org_features(org_guid)
    if not payload:
        raise HTTPException(status_code=503, detail={...})   # CP unreachable -> fail closed
    gate = payload.get("gate") or {}
    if gate.get("access") == "blocked":
        # 402 Payment Required, with an actionable {kind, label, url} so the UI
        # can route the user to renew / update card / choose a plan.
        raise HTTPException(status_code=402, detail={
            "error_code": 402, "success": False, "status": "error",
            "reason": gate.get("reason"),
            "message": gate.get("message"),
            "action": gate.get("action"),
        })
    return gate

Permission is two questions, not one (BOLA + BFLA)

On a real multi-tenant route, "authorized" means two independent checks — and both must pass. This is the tenant EAV portal router that serves dynamic entity data:

  • BOLA — are you in this tenant? The per-request tenant database is selected from the X-App-Instance-Guid header, but the header alone must never grant access. The route verifies the authenticated caller's org_guid (an unforgeable RS256 claim, minted only for orgs the user belongs to) actually owns the addressed instance, via the Control Plane authority. It fails closed: if ownership can't be confirmed, access is refused.
  • BFLA — may you perform THIS action? After ownership, RequireAuthz("egav_entities") enforces the per-action permission, so a read-only member can't create, update, or delete.

The two gates are separate concerns and are checked in sequence:

routes/dynamic/portal.pypython
from egav_authz_client import RequireAuthz
from app.middlewares.authentication import validate_portal_bearer
from app.api.v1.egav.lib.instance_ownership import assert_org_owns_instance

# GET->read, POST->create, PUT->update, DELETE->delete on the 'egav_entities' resource
_entities_authz = RequireAuthz("egav_entities")

async def _portal_dep_with_identity(request, identity=Depends(validate_portal_bearer), redis=Depends(get_redis)):
    # 1) BOLA — the caller's org must own the addressed app instance (fails closed -> 503)
    await assert_org_owns_instance(
        org_guid=getattr(identity, "org_guid", None) or None,
        instance_guid=_tenant_guid_from_request(request),
        app_code=getattr(settings, "APP_CODE", "egav"),
        redis=redis,
    )
    # 2) BFLA — the caller must hold the per-action permission on entities
    await _entities_authz(request, identity=identity)

Instance-scoped permission — the ReBAC form

The BFLA check above asks may you do this action at all? For apps that declared resource types with instance roles, RequireAuthz answers a narrower third question — may you act on this specific record? Name the path (or query) parameter carrying the instance GUID with resource_instance_guid_from, and the gate forwards it to AuthZ, which allows the action when the caller holds an instance role on that record (e.g. owner) or the org-wide permission as an admin override:

routes/support/portal.pypython
from fastapi import Depends
from egav_authz_client import RequireAuthz

# Bare form: action derived from the HTTP method, org-wide permission only.
RequireAuthz("support.comment")                 # GET->read, PUT->update, DELETE->delete

# Explicit action, for resources that split self vs all.
RequireAuthz("users", "update_self")

# Instance-scoped (ReBAC): the author may edit *their* comment even without the
# org-wide `support.comment.update`. `comment_guid` is the route's path param.
@router.put("/v1/support/portal/comments/{comment_guid}")
async def edit_comment(
    comment_guid: str,
    _authz=Depends(RequireAuthz("support.comment", "update",
                                resource_instance_guid_from="comment_guid")),
):
    ...

The instance-role grants themselves are declared in your manifest and synced into AuthZ at publish — see Identity & access; this page is only the request-time half. The CP-operator equivalent, RequireCPAuthz, takes the same resource_instance_guid_from.

The status-code contract

Every gate maps to exactly one status, and that mapping is stable across services and languages — integrate against it directly.

StatusMeaningSource
401 Unauthorizedmissing, invalid, expired, or revoked bearer; unknown x-api-keytier auth
402 Payment Requiredsubscription lapsed (with an actionable next-step payload)require_active_subscription
403 Forbiddenpermission denied, feature not in plan, wrong tier, or missing org contextRequireAuthz / require_feature
429 Too Many Requestsplan quota exceededrequire_quota
503 Service UnavailableAuthZ, Control Plane, or Redis unreachableany gate

The distinction between 403 and 503 is deliberate: a gate returns 403 only when its dependency responds and denies. If the dependency is unreachable, the answer is 503 — the request is refused, not degraded to allow. Security and entitlement gates fail closed. (Best-effort caches elsewhere in the platform may fail open, but never the gates on this page.)

Every non-2xx shares one envelope, so a client branches on error_code without parsing prose:

error envelopejson
{
  "success": false,
  "status": "error",
  "error_code": 402,
  "message": "Your subscription is inactive.",
  "action": { "kind": "renew", "label": "Renew plan", "url": "https://..." }
}

message is a stable, user-safe string keyed off error_code — never a raw exception, ORM error, or stack trace — and the request payload is never echoed back, so passwords and tokens can't leak through an error. Only the 402 carries an action (the renew / update-card / choose-plan next step); the other statuses omit it. Two headers ride along where they matter: rate-limited endpoints return X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset, and any 429 — quota or rate limit — includes Retry-After.

Same shape in other languages

The five-gate model isn't Python-specific. The consumer SDKs ship a matching gates surface — authenticate() then requirePermission(), requireActiveSubscription(), requireFeature(), enforceQuota() (Go uses PascalCase — Authenticate, RequirePermission, RequireActiveSubscription, RequireFeature, EnforceQuota) — that maps failures to the same 401 / 402 / 403 / 429 / 503 contract, so a service written in another language enforces identical entitlements against the same Control Plane. Consult each SDK's own package reference for its installation coordinates and exact call signatures.

This page covers whether a request gets in. For who the caller is — reading the resolved identity, the user's profile, their organization, and other members, in every SDK — see Users & organizations.