Skip to content

Authentication

Every request to the Packt platform arrives with a token that says who is asking and on whose behalf. There are no API keys and no shared secrets passed in headers: a caller presents a signed, short-lived JSON Web Token (JWT), the platform verifies it, and the identity inside it travels with the request all the way to the service that does the work. If you are integrating with the Public API, this page is how you get one of those tokens and what it will contain.

There are three kinds of caller, and only three: a person signing in; a third-party application acting for a person who has granted it consent; and a service — including a platform service or a back-end integration of your own — acting as itself. All three obtain a token from the same issuer, and, crucially, all three end up carrying the same set of claims. A service that validates a request never has to ask how the token was obtained; it reads the same fields every time.

Two properties are worth holding in mind before the detail. First, tokens are short-lived — one hour at most — so a leaked token is a small, expiring problem rather than a standing key to the estate. Second, a token is always scoped to exactly one tenant: the isolation boundary between businesses is baked into the credential itself, not bolted on by a header a caller could get wrong. There is no super-token that spans tenants, and no service holds more authority than the scopes it was registered with.

This page covers the three flows, what every token carries, why the platform works this way, and how a validated token becomes an attributed Activity. For the catalogue of scopes themselves, see the Scope Registry (in the API reference); for how tenancy is enforced everywhere, see Tenancy & security.

The three caller flows

The platform's identity provider is WorkOS. It is the only issuer of platform tokens, serving two sign-in pools — one for customers and authors, one for staff and partner businesses (Sign-in & identity pools) — and it exposes three ways to get a token, one per kind of caller. What matters for an integrator is that you pick the flow that matches who your caller is: whichever you pick, and whichever pool your caller lives in, the token that comes out is shaped identically.

flowchart LR
    H["A person<br/>editor · author · admin"] -->|"AuthKit<br/>login + enterprise SSO"| W["WorkOS<br/>the only issuer"]
    APP["A third-party app<br/>acting for a person"] -->|"OAuth + hosted consent"| W
    SVC["A service<br/>acting as itself"] -->|"M2M client credentials"| W
    W -->|"one identical<br/>claim set"| T["Signed JWT<br/>iss · sub · tenant_id · scopes · client_id · exp/iat/jti"]
    T --> API["Public API<br/>and every internal service"]

People — sign-in via AuthKit

A human being — an editor in the product team, an author checking their royalties, an administrator — signs in through AuthKit, WorkOS's hosted login. This covers plain email/password and social login, and, for enterprise customers, single sign-on: a business can federate its own identity provider (Okta, Entra ID, Google Workspace, any OIDC or SAML provider) so its staff log in with their existing corporate account. From the platform's point of view, all of these produce the same result — a signed-in person mapped to a u_ principal in exactly one tenant.

The person's browser session refreshes through AuthKit as they work, so the short-lived access token is continually renewed without them noticing. Your application never handles a password; it hands off to AuthKit and receives a token back.

An application built by someone else — a Zapier connector, a customer's internal tool, an AI agent acting for a named user — uses the OAuth authorization-code flow with hosted consent. The person is sent to a WorkOS-hosted consent screen, sees exactly what the app is asking to do, and approves (or declines). The token the app receives then acts for that person, within that person's tenant.

The key rule for third-party apps is scope intersection. The token carries only the scopes that are in both sets: what the application was registered to request, and what the user actually consented to. An app can never acquire, through a user, a capability it was not itself granted — and a user can never be silently opted into more than they agreed to. The narrower of the two always wins.

Services — machine-to-machine client credentials

A service acting as itself — a back-end job, a platform service calling another, your own integration running unattended — uses the client-credentials grant (machine-to-machine, or M2M). There is no user and no browser: the service presents its registered client credentials and receives a token whose scopes are exactly the ones its application was registered with. The token acts for the service's own a_ principal, in the one tenant it requested.

A service that works across several tenants does not get a token that spans them. It requests a separate token for each tenant it is acting on, and caches each one until it expires — one token per service and tenant. This is what makes "one tenant at a time" a property of the credential rather than a convention.

Same claims, whichever flow

Whichever of the three flows a token came from, it carries the same claim set (below). WorkOS is configured — JWT templates, external IDs, M2M registration — so that a person's token, an app-acting-for-a-person token, and a service token are indistinguishable in shape. Services validate claims; they never branch on the grant type.

What every token carries

A platform JWT is signed with RS256 and verified against WorkOS's published JSON Web Key Set (JWKS). Beyond the standard signature and timing, every token carries the same claims regardless of caller:

Claim What it means
iss The issuer — the WorkOS environment's issuer URL.
sub The principal the token acts as: a u_ user or an a_ application ID.
tenant_id The single tenant this token is scoped to. Every token names exactly one.
scopes The capabilities the token grants — registry names verbatim, e.g. content-lake.read. One uniform claim for people, apps and services.
client_id The a_ application through which the token was obtained. Equals sub for a service's own token; differs when a user acts through an app.
auth_time When the person last actively signed in — refreshes never advance it. Human tokens only; a service token never carries it.
exp / iat / jti Expiry, issued-at, and a unique token ID.

Three rules govern these tokens, and they are the same for everyone:

  • Short-lived — one hour at most. There are no long-lived bearer credentials anywhere on the platform. Human sessions refresh through AuthKit; services re-request and cache M2M tokens until they expire.
  • No groups in the token. A token never carries group membership. Whether a principal belongs to a group is resolved server-side at the moment a permission is checked, so a token can never go stale when someone's membership changes — and cannot be enlarged by editing it.
  • Scope intersection for third-party apps. As above: an app-for-user token carries the intersection of the app's registered scopes and the user's consented scopes. A service's own token simply carries its registered scopes.
  • Some human actions require a recent sign-in. A small set of sensitive, human-only operations checks the token's auth_time and refuses a stale one with AUTH_STALE — the caller re-authenticates (a quick re-prompt, not a fresh login) and retries. Because auth_time only ever advances on a real interactive sign-in, a long-refreshed session cannot satisfy the check by waiting.

To use a token, put it in the Authorization header on every request:

GET /v0/content-lake/resources/res_ml_go_ch3 HTTP/1.1
Host: api.packtpub.services
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

The tenant is read from the token's tenant_id claim. There is no tenant header to set, and there is nothing else to send — the token is the whole of your identity.

The claims in full, and a decoded token

Every claim a platform JWT carries, with an example value:

Claim Example Notes
iss https://auth.packtpub.services The WorkOS environment issuer URL.
sub u_amelia_editor A u_ user, or an a_ application for M2M.
tenant_id packt Opaque in production; the slug is illustrative.
scopes ["content-lake.read", "products.read"] Registry names verbatim; dot-delimited.
client_id a_dashboard_web The app the token came through.
auth_time 1751806900 Last interactive sign-in; human tokens only.
exp 1751812800 Unix seconds; ≤ 1 hour after iat.
iat 1751809200 Unix seconds.
jti tok_9f3ac21b Unique token ID (opaque).

A decoded RS256 access token for a person, Amelia, acting through the web dashboard in the packt tenant:

{
  "header": {
    "alg": "RS256",
    "typ": "JWT",
    "kid": "key_2026_04"
  },
  "payload": {
    "iss": "https://auth.packtpub.services",
    "sub": "u_amelia_editor",
    "tenant_id": "packt",
    "scopes": ["content-lake.read", "content-lake.write", "products.read"],
    "client_id": "a_dashboard_web",
    "auth_time": 1751806900,
    "iat": 1751809200,
    "exp": 1751812800,
    "jti": "tok_9f3ac21b"
  }
}

A service token differs in that sub is an a_ ID and equals client_id, and there is no auth_time — a machine never signs in interactively:

{
  "payload": {
    "iss": "https://auth.packtpub.services",
    "sub": "a_ingestion_worker",
    "tenant_id": "packt",
    "scopes": ["content-lake.write"],
    "client_id": "a_ingestion_worker",
    "iat": 1751809200,
    "exp": 1751812800,
    "jti": "tok_5d81fe0a"
  }
}

Why there are no shared super-tokens

It is worth being explicit about a design choice an integrator will feel: nothing on the platform holds broad, standing authority. A service's power is exactly the scopes its application was registered with, and it can act on one tenant at a time — the tenant named in the token it requested. There is no master credential that reads across tenants, no network-level bypass where "internal" traffic is trusted by position, and no way to widen a token after it is minted.

This is the platform's zero-trust posture made concrete. Every service validates every request independently — checking the signature and issuer, that the token's tenant_id matches the tenant the request operates on, and that the required scope is present — so passing the edge proves nothing on its own. A leaked service credential is bounded to one service's scopes in one tenant, and it expires within the hour. The blast radius of any single mistake is small by construction.

One tenant per token

A request that names no tenant, or whose token is scoped to a different tenant than the one being operated on, is refused (TENANT_MISMATCH). If your integration spans tenants, acquire and cache one token per tenant — never try to reuse a token across the boundary. See Tenancy & security.

From a token to an attributed Activity

Authentication answers who is asking; the platform also records who did what. Every consequential action is written to the Activity graph as one or more agents (agt_) — the system's durable record of authorship and provenance. The bridge from the token on a request to the agent on an Activity is automatic, and it follows one firm set of rules.

flowchart LR
    U["Token: u_ user"] -->|resolves to| PA["The person's agent<br/>agt_ (HUMAN)"]
    A["Token: a_ service (M2M)"] -->|resolves to| SA["A SYSTEM agent<br/>agt_ (SYSTEM)"]
    PA --> ACT["Activity<br/>performed_by"]
    SA --> ACT
    EXT["Caller-supplied<br/>additional agents"] -.->|extend, never replace| ACT
    LLM["An LLM / model"] -.->|recorded in tooling,<br/>never an agent| ACT
  • The token's principal fixes the default agent. A u_ principal resolves to that person's agent; an a_ service (client- credentials) principal resolves to a SYSTEM agent for that service. The writing service stamps this from the token — so attribution can never be empty, and a caller can never omit itself.
  • Callers may extend, never replace. A caller can name additional agents alongside the derived one — for example, ingestion writing on behalf of an author names the author too. The service checks that any supplied agent exists in the registry. The agent derived from the token is always present; it cannot be swapped out.
  • Models are never agents. An LLM or other tool used to produce content is recorded in the Activity's tooling, never in performed_by. Attribution is reserved for people and services with real accountability. See Provenance.

The effect is that provenance is trustworthy by default: you cannot perform a consequential action without leaving an accurate trace of who you were, and you cannot dress a model up as an author.

Under the hood

The detail below is background for the technically curious. None of it changes anything you send: the token contract, the header, and the validation you can rely on are exactly as described above.

How service-to-service tokens may evolve

Today every token — for people, third-party apps, and services alike — is issued by WorkOS. WorkOS is the right issuer wherever authentication is a product: human login, enterprise SSO, hosted consent for third-party apps, and machine credentials issued to business customers who need to see and revoke them.

Internal service-to-service issuance is different in kind — a small set of known first-party callers, no consent, no federation — and the platform reserves the option to move that one flow to an internal issuer in future, for cost and availability reasons. The important part for an integrator: the token contract would not change. Same claims, same shapes, same scope strings, same validation order, and the same tokens still travelling unchanged from edge to service. People, third-party apps and customer M2M credentials stay on WorkOS regardless. The internal design (how such an issuer would sign and authenticate callers) is internal-only and out of scope here.