Build your app

Protecting your API

Define an API, request tokens for it and verify them: locally, with live revocation (hybrid) or by introspection.

View .md llms.txt

Your API receives access tokens in the Authorization: Bearer … header and decides whether to accept them. 9in gives you three ways to check them, trading speed for how quickly revocations take effect.

#Define the API

APIs & Scopes → Create API:

  • Identifier: a URL-like name, usually the API's base URL (https://api.acme.com). Apps request tokens for it with audience=https://api.acme.com, and tokens carry it as aud.
  • Scopes: what the API lets callers do, such as orders:read and orders:write. Apps request them in scope; users approve them on the consent screen when the app isn't first-party.
  • Optional: token format (JWT or opaque), signing key, access token lifetime, and whether refresh tokens (offline_access) may be issued for it.

#Verify tokens

#Local (fastest)

Check the signature against the tenant's public keys and the claims. No call to 9in per request; the keys are fetched once and cached.

ts
import { verifyAccessToken } from "@9in-io/sdk/server";

const claims = await verifyAccessToken(bearer, {
  issuer: "https://acme.9in.io",
  audience: "https://api.acme.com",
  scope: "orders:read",          // every scope listed must be present
});
// claims.sub — the user (or the client ID for machine tokens)
// claims.scope, claims.client_id, claims.sid, claims.jti, claims.exp

A token that was revoked (sign-out, a blocked user, /oauth/revoke) still passes until it expires. Keep access tokens short-lived (the default is 1 hour) if you rely on this alone.

A local check, then one introspection per token, cached. 9in's token.revoked, session.revoked and user.* webhooks drop the cached answers, so revocations apply within seconds while most requests stay local.

ts
import { createTokenVerifier, verifyWebhook } from "@9in-io/sdk/server";

const verifier = createTokenVerifier({
  issuer: "https://acme.9in.io",
  audience: "https://api.acme.com",
  clientId: process.env.NINE_CLIENT_ID,          // any confidential application of the tenant
  clientSecret: process.env.NINE_CLIENT_SECRET,
  // mode: "hybrid" (default) | "local" | "introspect"
  // cacheTtl: 300   seconds an answer is trusted without news from 9in
});

const claims = await verifier.verify(bearer, { scope: "orders:read" });

// your webhook endpoint, subscribed to token.revoked, session.revoked, user.sessions_revoked, user.updated and user.deleted
verifier.handleEvent(await verifyWebhook(process.env.NINE_WEBHOOK_SECRET!, request));

The cache lives in memory, per process: with several processes, each needs the webhook events (or keep cacheTtl short).

#Introspection

mode: "introspect" asks 9in about every token (POST <issuer>/oauth/introspect, authenticated as a confidential application). Always current, one network call per request. Opaque tokens are always introspected, and cached in hybrid mode.

#Without the SDK

Any JWT library works. Verify:

  1. The header: typ is at+jwt, and alg is the algorithm of the key named by kid in <issuer>/.well-known/jwks.json. Don't accept a fixed algorithm list that includes others, and never none.
  2. The signature, with that key.
  3. iss equals your issuer exactly; aud contains your API identifier; exp is in the future (allow a few seconds of clock skew).
  4. scope (space-separated) contains what the route needs.
python
# Python with PyJWT
import jwt
jwks = jwt.PyJWKClient("https://acme.9in.io/.well-known/jwks.json")
key = jwks.get_signing_key_from_jwt(token)
claims = jwt.decode(token, key.key, algorithms=[key.algorithm_name], audience="https://api.acme.com",
                    issuer="https://acme.9in.io", options={"require": ["exp", "iss", "aud"]})
assert jwt.get_unverified_header(token)["typ"] == "at+jwt"

Cache the JWKS, and fetch it again when a token names a kid you don't know (keys rotate).

#Shared-secret (HMAC) tokens

An API can use an HS256/384/512 key instead of a public key. Its secret is shown once in Tokens & Keys; the API verifies with it:

ts
const claims = await verifyAccessToken(bearer, { issuer, audience: "https://api.acme.com", secret: process.env.NINE_API_SECRET });

HMAC keys never appear in the JWKS, and a token signed with one is only accepted for the API (or application) that uses that key, so a leaked secret can't mint tokens for other audiences. Prefer asymmetric keys unless your platform requires HMAC.

#Opaque tokens

With Opaque as the API's token format, access tokens are random strings starting with 9in_at_. They carry nothing, can't be verified locally, and are checked with introspection. Use them when tokens must not reveal anything to the client, or when every request should see revocations at once.

#Machines vs users

A client-credentials token has sub equal to client_id and no sid. Check for that when a route is meant for users only.