Authentication
The API requires two independent authentication layers, validated in this order:
- mTLS (transport-level) — verified by AWS CloudFront in front of Vercel.
- 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 theaccount_public_idincluded as a SAN. - Revocation: implemented at the application layer via the
RevokedClientCertstable, 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:
| Header | Meaning |
|---|---|
cloudfront-viewer-mtls-client-cert-subject | Subject DN of the client certificate |
cloudfront-viewer-mtls-client-cert-issuer | Issuer DN |
cloudfront-viewer-mtls-client-cert-serial-number | Serial number |
cloudfront-viewer-mtls-client-cert-fingerprint | SHA-256 fingerprint |
cloudfront-viewer-mtls-client-cert-validity-not-after | Cert expiry |
cloudfront-viewer-mtls-client-cert | Full 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 onlyRS256. - 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
kidthat 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
| Claim | Source | Description |
|---|---|---|
iss | Authorization server | Its identifier, as a URL: https://account[.{env}].kobana.com.br. |
iat | Unix timestamp | Issued at. |
aud | This API, as a URL | https://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. |
jti | AccessToken public_id | Unique token id, used for revocation. |
sub | users.public_id | The person, never the tenant. Resolves the operator and their permissions when src=user; on a machine token it is carried for attribution. |
org | accounts.public_id | The tenant, and it is required. This is what scopes every response. A token without it is refused rather than falling back to sub. |
src | user / system / api | Origin of the token. |
exp | required | Unix 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. |
scope | string | Space-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.
| Scope | Grants |
|---|---|
inbox/workspaces | Read Workspaces |
inbox/inboxes | Read Inboxes and allowed recipients |
inbox/emails | Read Emails (and download .eml) |
inbox/agents | Read Agents, prompt versions and the LLM model catalog (/models) |
inbox/agent_runs | Read AgentRuns |
inbox/usage | Read aggregated usage and cost (/usage/*) |
inbox/webhooks | Read Webhooks and their deliveries |
inbox/system_events | Read SystemEvents |
inbox/secrets | Read Secrets — key, notes and metadata. The value is never returned by any endpoint, so this read is no weaker than any other |
inbox/workflow_runs | Read WorkflowRuns and every step of each one |
inbox/all | Resource catch-all — reads every resource above, grants no verb |
write | Global 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:
- Verify
X-Origin-Shared-Secret—403if absent/divergent. - Verify mTLS headers (
cloudfront-viewer-mtls-client-cert-fingerprintpresent). - Extract
Authorization: Bearer <jwt>. jwtVerifywith an allow-list of exactly one algorithm (RS256) against the authorization server's published public key. Reject on signature failure.typis deliberately not checked: the authorization server stampsat+jwt(RFC 9068), and demanding"JWT"would reject every real token.- Validate
iss, thataudcontains this API's identifier, and thatexpis present and in the future. - Resolve the organization from
org, then the workspace inside it (X-Workspace-Idpicks one when the organization has more than one). - Check scope vs endpoint.
- Optional revocation check by
jti. - 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
| HTTP | code | Cause |
|---|---|---|
| 401 | mtls_required | Client certificate missing or invalid |
| 401 | invalid_token | JWT malformed or signature invalid |
| 401 | invalid_audience | aud does not match the environment |
| 401 | token_expired | exp in the past |
| 401 | token_revoked | jti listed in RevokedTokens |
| 403 | insufficient_scope | Token lacks the required scope |
| 403 | insufficient_permission | The 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 |
| 403 | forbidden | message 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 |
| 400 | workspace_required | The organization has more than one workspace and none was selected. Not an authorization failure — the token is fine; add X-Workspace-Id |
| 500 | as_unavailable | The 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.