Skip to content

Authenticating your application

This is how your application proves who it is to this platform. There is no user, no login and no session involved. You generate a key pair on your own machine, your administrator registers the public half, and you sign a short-lived token with the private half.

This platform never generates, transmits, holds or downloads your private key. No endpoint has a field for one and no response returns one. A private key pasted into the key-registration form is refused before it is stored, as key_material_is_private.


1. Find out what this deployment expects

curl -s https://<host>/v1/integrator/discovery
{
  "audience": "intouch-integrator-api",
  "algorithm": "RS256",
  "claims": ["iss","sub","aud","tenant_id","scopes","exp","nbf","iat","jti"],
  "required_claims": ["iss","aud","tenant_id","scopes","exp"],
  "scopes": {
    "tokens:mint": "Mint a join token for an occurrence, and promote a visitor.",
    "meetings:read": "Read scheduling resources.",
    "meetings:write": "Create a meeting or a recurring series.",
    "artefacts:read": "Browse the artefact library.",
    "recordings:control": "Start and stop recording.",
    "consents:read": "Read consent receipts and export the evidence report.",
    "entitlements:read": "Read the tenant's effective entitlements.",
    "usage:read": "Read metering and usage.",
    "audit:read": "Query and export the tenant audit log; read the isolation attestation.",
    "webhooks:manage": "Manage webhook endpoints.",
    "kyc:manage": "The KYC operating surface.",
    "kyc:evidence": "The KYC evidence surface."
  },
  "kyc_scopes": ["kyc:manage", "kyc:evidence"],
  "clock_skew_seconds": 60,
  "max_token_lifetime_seconds": 3600,
  "max_active_keys_per_application": 5,
  "rate_limit_per_minute": 120
}

No credential is needed for this call. Read these values rather than hard-coding them: a deployment is allowed to differ, and this is where it says so.

2. Generate a key pair on your own machine

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem
openssl rsa -in private.pem -pubout -out public.pem
chmod 600 private.pem

private.pem stays with you. It never leaves your infrastructure, and nothing in this document asks you to send it.

RSA, 2048 bits or more. RS256 is the only algorithm this deployment verifies, and it is fixed in the verifier rather than read from your token's header, so there is no algorithm for an attacker to negotiate downwards.

If you already hold a key in another format, the refusal names the format you sent and gives the conversion:

What you have Conversion
OpenSSH (ssh-rsa AAAA…) ssh-keygen -e -m PKCS8 -f id_rsa.pub > public.pem
PKCS#1 (BEGIN RSA PUBLIC KEY) openssl rsa -RSAPublicKey_in -in pkcs1.pem -pubout -out public.pem

3. Ask your administrator to register the application and the key

A workspace administrator signs in at https://<host>/app, opens Developer console, and registers an application by name. The screen answers with the application identifier, which is the iss claim of every token you sign.

On that application's page, Add a public key: they paste the contents of public.pem. The screen answers with two values to send back to you.

Value What you do with it
kid, the key identifier Goes in your token header. It is not derivable from the key material, and the only place it comes back is authenticated_with.kid on GET /v1/applications/me — which needs a token that already carries it.
fingerprint_sha256 Check it against your own file.
openssl pkey -pubin -in public.pem -outform DER | openssl dgst -sha256 -r | cut -d' ' -f1

That prints the same value as fingerprint_sha256.

Registering an application and registering a key are separate acts on separate resources, because rotation is register, deploy, retire: an application holds several live keys at once, so there is no moment in the middle where you hold no working credential. See §5.

Both are administrator operations, in a browser session. A credential cannot register a credential.

4. Sign a token

Header

{ "alg": "RS256", "typ": "JWT", "kid": "k_9f3c1d2e4a5b6c7d8e9f0a1b2c3d4e5f" }

Claims

{
  "iss": "<application id>",
  "aud": "<the audience from discovery>",
  "tenant_id": "<tenant id>",
  "scopes": ["audit:read"],
  "exp": 1787700000,
  "iat": 1787699700,
  "jti": "<a unique string>"
}

aud is shown as a placeholder. On this deployment discovery answers intouch-integrator-api; read the value from discovery rather than copying it from here, because a deployment is allowed to differ.

Claim Required Notes
iss yes your application's UUID
aud yes exactly the audience from discovery
tenant_id yes the tenant this application belongs to
scopes yes a non-empty array from the twelve in discovery
exp yes integer seconds since the epoch
sub no if you send it, it must equal iss
nbf no integer seconds
iat no integer seconds
jti no non-empty string

The header takes alg, typ and kid, and nothing else. alg and kid are required; a token whose header carries no kid is refused as token_malformed. typ is optional, and where you send it the only accepted value is "JWT". A header member this API does not define is refused as token_claim_unknown at /header/….

The claim set is closed. Any other member is refused, by name, with a JSON Pointer to it. A misspelled claim, or one nested inside a wrapper object, is an error rather than a token that works and silently does less than you intended.

A required claim that is absent is token_claim_malformed with errors[].code: required, not a separate code. Read errors[].code alongside the pointer: required for absent, type for the wrong JSON type, format for the wrong shape, unknown for a scope this API does not define.

The scope set is closed too. There are exactly twelve, listed in discovery. A value outside them is refused with its index rather than ignored.

KYC scopes never share a credential with the rest. kyc:manage and kyc:evidence are one class; the other ten are another. A token naming both classes is refused with token_scope_classes_mixed. Sign two tokens.

Send it as an ordinary bearer credential:

curl -s https://<host>/v1/applications/me \
  -H "Authorization: Bearer <the signed JWT>"
{
  "application": { "id": "…", "tenant_id": "…", "name": "orders-service", "status": "active", "framing_origins": [], "created_at": "…" },
  "authenticated_with": { "kid": "k_9f3c…", "fingerprint_sha256": "8f2b…" },
  "scopes": ["audit:read"]
}

scopes in that response is what your token actually carries. Check it before anything else depends on the credential.

Fix one fault at a time. The credential is checked in a fixed order: the three-segment shape; then the header and claims — which members they carry, and the values of alg, typ, sub, tenant_id and scopes; then the key named by kid; then the signature; and last the values of aud, exp and nbf. The first failure is the one reported, so until a registered kid resolves, a wrong audience, an expired token and a bad signature all read as token_key_unknown — but a bad sub or a malformed tenant_id does not, and is reported straight away.

This route enforces no particular scope: a credential identifying itself is not a grant over a resource, and you should be able to test your key before you have decided anything else. It does still need a token naming at least one scope. An empty scopes array is refused with token_claim_malformed at /claims/scopes.

You may send X-Tenant-Id. If you do, it must match the token's tenant_id; a request whose header and credential disagree is refused rather than resolved in either direction.

What token lifetime buys

exp gates admission. It bounds how long a leaked token can be redeemed. It does not terminate anything that has already started, and it is not a session length. Keep tokens short — minutes — and sign one per request or per short burst. A lifetime longer than max_token_lifetime_seconds is refused.

Spending a scope

curl -s "https://<host>/v1/audit-events?limit=5" \
  -H "Authorization: Bearer <token whose scopes include audit:read>"

A token without the scope is refused with 403 scope_not_granted, naming what was needed. A token from another tenant sees none of your events.


5. Rotation, with no dead window

An application may hold several active keys at once. Rotation is register, deploy, retire, and there is no moment in the middle where you hold no working credential.

  1. Generate the next pair on your own machine, as in §2, and send the new public.pem to your administrator.
  2. Ask them to add it on the application's page. Both keys verify from that moment.
  3. Deploy the new private key in your application.
  4. Ask them to revoke the old key.

Before step 4, the console shows when the old key last signed an accepted token. Read it rather than discovering the answer from an outage.

Revocation takes effect immediately. A token signed with a revoked key is refused from that moment, whatever its exp says, with token_key_revoked — a different code from token_key_unknown, so "switched off" and "never heard of it" can be told apart.

Revoking the last active key is permitted, so that a leaked key can be withdrawn without waiting for a replacement to be arranged.

Your application cannot rotate its own keys. No scope grants credential self-management; the operation stays with a workspace administrator.

6. When a request is refused

Every code on this API — not only the ones below — is catalogued with its cause and its remedy in the Error reference.

Every refusal is an RFC 9457 problem document with a stable code, a detail written for a person, a correlation_id, and — where a specific member is at fault — errors[] with a JSON Pointer naming it.

{
  "type": "about:blank",
  "title": "Unauthorized",
  "status": 401,
  "code": "token_expired",
  "detail": "The token expired at 1787699400 (2026-08-28T21:50:00+00:00), which is 3600 seconds ago; this deployment's clock reads 1787703000 (2026-08-28T22:50:00+00:00). Tolerance is 60s either way — if that offset looks like your clock rather than your code, check NTP on the signing host.",
  "correlation_id": "01a04a82-068b-7298-b042-be7b1bbda8dc",
  "errors": [{ "pointer": "/claims/exp", "code": "expired" }]
}

401 — the credential is not usable

Code What happened What to do
token_missing no Authorization: Bearer send one
token_malformed not a three-segment JWS, or a segment is not unpadded base64url check your encoder; segments are base64url without padding, and it is enforced (RFC 7515 §2) — the detail names which of the two faults it was and the call that fixes it: Node Buffer.toString('base64url'), Python base64.urlsafe_b64encode(x).rstrip(b'='), PHP rtrim(strtr(base64_encode($x), '+/', '-_'), '=')
token_algorithm_unsupported alg is not RS256 sign with RS256
token_claim_unknown a header or claim member this API does not define remove it, or fix the spelling; the pointer names it
token_claim_malformed a member of the wrong type, shape or value the detail names the expected type; a quoted number is a string
token_key_unknown no key with that kid for that tenant check kid and tenant_id
token_key_revoked the key was revoked sign with a live key
token_signature_invalid signature does not verify against the registered key the private key you signed with is not the pair of the key that was registered; compare fingerprint_sha256 against the command in §3
token_expired exp is past the detail names both clocks; check NTP
token_not_yet_valid nbf is future same
token_lifetime_excessive exp - iat above the maximum sign short-lived tokens
token_scope_classes_mixed a KYC scope alongside a general one sign two tokens
token_audience_invalid addressed to another deployment use audience from discovery
token_issuer_mismatch iss names a different application than the key belongs to check iss
no_valid_principal no credential at all, or an application credential on a route that takes a browser session only send a credential; if your token works elsewhere, that route is session-only

Those are the codes about the credential itself. The Error reference is the complete list, including the ones that reach a person signing in rather than an application.

403 — the credential is valid, the request is not permitted

Nothing in this group is fixed by rotating a key. These four are about the credential; the Error reference carries the rest, which are about the operation you asked for.

Code What happened
token_tenant_mismatch X-Tenant-Id names a different tenant than the token
tenant_suspended the tenant is suspended; the signature was fine
application_suspended the application is suspended; the signature was fine
scope_not_granted the token does not carry the scope the operation needs; the detail names it

429 — rate limited

Two limiters sit in front of these surfaces, and the headers report whichever one the response passed through.

The one you budget against is per application, published as rate_limit_per_minute in discovery. It runs after your credential is verified, and it sets X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset on every response it sees, not only on the 429.

In front of the credential check, on every surface that performs one, sits a per-address limiter. It bounds how many credentials an address may fail to verify in a minute. A credential that verifies is not charged to it, so it sits beside your application budget rather than under it: an application making its published rate_limit_per_minute requests from one address never meets it.

The consequence to design for: while an address is over that limit, a credential that would verify is refused too, because the limiter runs before the check and cannot know. If you share an egress address with something that is failing to authenticate, you will feel it — find that credential rather than retrying through it. The detail on the 429 says which limiter refused you.

GET /v1/integrator/discovery and GET /v1/applications/me additionally carry a per-address limiter that reports itself in X-RateLimit-Limit and X-RateLimit-Remaining, which is why those two show a number lower than your application budget.

So: read the headers as your application's budget only on a response to a request that carried a verified credential. Where they appear on any other response — a 401, or GET /v1/integrator/discovery, which needs no credential — they report the per-address limiter instead, and the number will be lower. Most refusals carry no rate-limit headers at all, so their absence tells you nothing. Budget against rate_limit_per_minute from discovery.

GET /v1/applications/me passes through both limiters and reports the per-address number, because the outer one writes last.

X-RateLimit-Reset is an absolute unix timestamp; Retry-After, present on the 429, is a duration in seconds.

A refused request does no work — every mutation runs in one transaction — but it does spend budget. The charge is taken before the operation starts, so a run of refusals counts against you exactly as a run of successes would.

Registration refusals

These are the refusals your administrator sees when registering or revoking a key. The key_material_*, key_algorithm_unsupported and key_too_small rows are 422 and name /public_key; the rest carry the status shown and no pointer.

Code What happened
key_material_missing nothing was supplied
key_material_is_private a private key was sent. It was not stored, not logged and not transmitted onward. Treat it as exposed anyway: delete it, generate a fresh pair, upload the public half
key_material_openssh_format OpenSSH format; the detail gives the conversion
key_material_pkcs1_format PKCS#1 format; the detail gives the conversion
key_material_unreadable not a readable PEM public key
key_algorithm_unsupported not RSA
key_too_small RSA below 2048 bits
key_already_registered (409) that exact key is already registered; the detail names its kid
key_limit_reached (409) the application is at its ceiling of active keys
key_not_found (404) no key with that id belongs to this application
idempotency_key_required (400) creating requests need an Idempotency-Key UUID header
idempotency_key_reused (409) that key was already used with a different body
idempotency_request_in_flight (409) the first request with that key has not finished
key_identifier_conflict (409) an identifier could not be generated for the key; retry

7. Tenancy

A token names one tenant and a set of scopes, and reaches nothing outside either.

This is enforced twice: the API refuses and says why, and the store underneath returns nothing belonging to another tenant, so a fault in the layer above cannot widen the boundary. Your key is looked up inside your tenant's scope, which means a token cannot reach a key outside the tenant it names.

A resource belonging to another tenant answers 404, not 403. The answer tells you nothing about what exists elsewhere.

8. Resolution of last_used_at

last_used_at on a key has one-minute resolution. It is not written on every request, so a key used seconds ago may still show the previous minute. Use it to answer "is anything still signing with this key", which is what it is precise enough for.