Skip to main content

Authentication

The API requires two independent authentication layers, validated in this order:

  1. mTLS (transport-level) — verified by AWS CloudFront in front of Vercel.
  2. Bearer JWT (application-level) — verified by the Next.js application.

Failure on either layer rejects the request.

mTLS​

  • Terminator: AWS CloudFront with MutualTlsAuthenticationMode=require, covering the whole API host (api.inbox.*). The SNS ingest endpoint on that host (/ingest) is the only exception. The application and the admin panel run on separate hosts, served by distributions without mTLS.
  • CA: one CA per environment (development, sandbox, production). Certificates issued under one CA will not validate against another.
  • Certificate cliente: issued per workspace/app consumer. Typical validity is one year with automated renewal.
  • CN/SAN: CN={consumer-slug}.inbox.kobana.com.br, with the account_public_id included as a SAN.
  • Revocation: implemented at the application layer via the RevokedClientCerts table, keyed by SHA-256 fingerprint. CloudFront does not consult CRL/OCSP automatically.

Headers injected by CloudFront​

Once mTLS succeeds, CloudFront injects the following headers into the request that reaches the origin:

HeaderMeaning
cloudfront-viewer-mtls-client-cert-subjectSubject DN of the client certificate
cloudfront-viewer-mtls-client-cert-issuerIssuer DN
cloudfront-viewer-mtls-client-cert-serial-numberSerial number
cloudfront-viewer-mtls-client-cert-fingerprintSHA-256 fingerprint
cloudfront-viewer-mtls-client-cert-validity-not-afterCert expiry
cloudfront-viewer-mtls-client-certFull PEM (if enabled)

The application validates: header presence, expiry (defence-in-depth), and a local revocation lookup by fingerprint.

An additional X-Origin-Shared-Secret header is enforced by middleware to prevent direct hits at the underlying *.vercel.app URL bypassing CloudFront. Missing/incorrect → 403.

Bearer JWT​

The token is an access token issued by the Kobana authorization server (account.kobana.com.br). This API only verifies it: it holds no signing key and cannot mint a token of its own.

  • Algorithm: RS256, and only RS256.
  • Key: public, and discovered, not configured. The issuer identifier is a URL, and the key endpoint (jwks_uri) is read from the RFC 8414 metadata document published under it (/.well-known/oauth-authorization-server). When the authorization server rotates its key, this API follows on its own — no redeploy, no coordinated window.
  • When the authorization server is contacted: never to verify a token — the signature check is local. What comes from it is the key set, and that is cached. A fetch happens only when the cache is empty (a freshly started process), when the cached key set is older than one hour, or when a token names a kid that is not cached (a rotation; at most one refetch every 30 seconds). Each fetch has a 5-second timeout. Outside those moments the authorization server can be down without any request noticing.

This replaced a shared HS512 secret. Under that scheme the same value signed and verified, so a leak on either side was a leak of issuance itself. Nothing about how you send the token changed; what changed is that a token minted with the old shared secret is no longer accepted.

Claims​

ClaimSourceDescription
issAuthorization serverIts identifier, as a URL: https://account[.{env}].kobana.com.br.
iatUnix timestampIssued at.
audThis API, as a URLhttps://api.inbox[.{env}].kobana.com.br. May be a list — the rule is contains, not equals, so one token can address more than one Kobana API. Any other audience is rejected.
jtiAccessToken public_idUnique token id, used for revocation.
subusers.public_idThe person, never the tenant. Resolves the operator and their permissions when src=user; on a machine token it is carried for attribution.
orgaccounts.public_idThe tenant, and it is required. This is what scopes every response. A token without it is refused rather than falling back to sub.
srcuser / system / apiOrigin of the token.
exprequiredUnix expiry. A token without it is rejected: "lives until someone revokes it" is not an expiry policy, and the revocation-cleanup job eventually drops the row that has no expiry, re-allowing a leaked token.
scopestringSpace-separated list of scopes.

Scopes​

Scopes have two orthogonal axes: the resource, always prefixed by the app (inbox/emails), and the verb, a global scope with no app prefix (write). A resource without a verb means read. Writing requires both: the resource scope and the verb.

ScopeGrants
inbox/workspacesRead Workspaces
inbox/inboxesRead Inboxes and allowed recipients
inbox/emailsRead Emails (and download .eml)
inbox/agentsRead Agents, prompt versions and the LLM model catalog (/models)
inbox/agent_runsRead AgentRuns
inbox/usageRead aggregated usage and cost (/usage/*)
inbox/webhooksRead Webhooks and their deliveries
inbox/system_eventsRead SystemEvents
inbox/secretsRead Secrets — key, notes and metadata. The value is never returned by any endpoint, so this read is no weaker than any other
inbox/workflow_runsRead WorkflowRuns and every step of each one
inbox/allResource catch-all — reads every resource above, grants no verb
writeGlobal verb: every write (POST, PUT, PATCH, DELETE) on the resources the token carries — creating, updating and deleting, plus submitting an .eml, reprocessing, testing a webhook, rotating a signing secret and replaying a delivery

Two examples: inbox/emails reads e-mails and cannot write; inbox/emails write also submits, reprocesses and deletes them, and still cannot touch webhooks.

The granular form inbox/emails:write is an exception of the grammar, not the default shape: it satisfies both axes for that one resource, and exists for "read one resource, write another". inbox/* is a registration-time wildcard and is never issued in a token — comparison here is always literal.

read is a Doorkeeper default scope with no meaning here: reading is the resource without a verb.

Tokens without the scope required by an endpoint receive 403 insufficient_scope.

Validation pipeline​

The auth middleware runs the following steps in order:

  1. Verify X-Origin-Shared-Secret — 403 if absent/divergent.
  2. Verify mTLS headers (cloudfront-viewer-mtls-client-cert-fingerprint present).
  3. Extract Authorization: Bearer <jwt>.
  4. jwtVerify with an allow-list of exactly one algorithm (RS256) against the authorization server's published public key. Reject on signature failure. typ is deliberately not checked: the authorization server stamps at+jwt (RFC 9068), and demanding "JWT" would reject every real token.
  5. Validate iss, that aud contains this API's identifier, and that exp is present and in the future.
  6. Resolve the organization from org, then the workspace inside it (X-Workspace-Id picks one when the organization has more than one).
  7. Check scope vs endpoint.
  8. Optional revocation check by jti.
  9. Attach auth_context = { workspace, jti, sub, src, scopes } to the request.

Multi-tenant isolation​

Every endpoint scopes its query by the Workspace derived from org. Clients may not override the workspace via body or query — any such field is ignored. Resources outside the resolved workspace return 404 not_found rather than 403 forbidden, to avoid leaking existence.

Failure modes​

HTTPcodeCause
401mtls_requiredClient certificate missing or invalid
401invalid_tokenJWT malformed or signature invalid
401invalid_audienceaud does not match the environment
401token_expiredexp in the past
401token_revokedjti listed in RevokedTokens
403insufficient_scopeToken lacks the required scope
403insufficient_permissionThe person behind the token (src=user) does not hold the permission the endpoint needs in this organization. message is the missing permission slug, so you know what to ask your administrator for
403forbiddenmessage says why: unknown_organization (the org claim names an account this API does not know), no_membership (the person has no live membership in that organization), unknown_workspace (X-Workspace-Id names a workspace outside the organization — indistinguishable from a nonexistent one), or the X-Origin-Shared-Secret was missing/wrong
400workspace_requiredThe organization has more than one workspace and none was selected. Not an authorization failure — the token is fine; add X-Workspace-Id
500as_unavailableThe signing keys could not be fetched from the authorization server: its metadata or JWKS endpoint timed out, answered an error, redirected, or served a document that does not match the issuer. Verification itself is local, so this only happens at the moments listed under When the authorization server is contacted above — a cold cache, a key set older than an hour, or a kid we have not seen. Not a problem with your token — retry shortly. It is reported as 5xx on purpose: a 401 here would send you debugging a perfectly good credential during our outage.

See Errors for the full error envelope.