Trust Model

How a separately-hosted app verifies a user’s identity and access without maintaining its own user table.

  1. Why apps shouldn’t own their own user table
  2. 1. Short-lived JWT at launch
  3. 2. Live introspection
  4. 3. Webhooks for push-based revocation
  5. How fast does revocation need to land?
  6. What the hub guarantees, what it doesn’t
  7. Trust runs the other direction too

This page’s core assumption is now resolved, not provisional: Decisions → Downstream app architecture confirms every Application is an independently hosted service — not just a web app, but potentially iOS, macOS, Windows, Linux, or any other platform. One gap that resolution surfaced and this page doesn’t yet cover: §1 below describes a web-style redirect launch; a native mobile/desktop app needs the equivalent via OAuth 2.0 Authorization Code + PKCE instead, carrying the same claims. Flagged inline below rather than silently assumed away.

Why apps shouldn’t own their own user table

Substratal Apps is the hub precisely so that a user’s identity, their access, and their account-wide settings exist once. An app that keeps its own copy of “is this user allowed in, and what are they allowed to do” will drift from the hub’s answer — most dangerously in the direction of an app still honoring access the hub has revoked.

Three mechanisms, meant to be layered, not chosen between:

1. Short-lived JWT at launch

When the hub redirects a user into an app (SSO-style launch from the dashboard, or a deep link), it issues a signed JWT scoped to that one app. This is the web launch flow; a native iOS/macOS/Windows/Linux app — see Decisions → Downstream app architecture — can’t receive a browser redirect, and instead completes an OAuth 2.0 Authorization Code flow with PKCE, returning to the app via a custom URL scheme or platform app-link rather than a server redirect. Same claims, same signature, same TTL discipline below — only the hand-off mechanics differ by platform.

{
  "iss": "https://api.substratalapps.com",
  "sub": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
  "aud": "app_timetrack",
  "org_id": null,
  "entitlement_status": "active",
  "effective_permissions": ["app.timetrack.export", "app.timetrack.manage_members"],
  "iat": 1730649600,
  "exp": 1730649900
}
  • Verified locally by the app (standard JWT signature check) — cheap, no network call, no dependency on the hub being reachable for every request.
  • exp should be short — minutes, not hours — because the claims are a snapshot. If an admin revokes the entitlement one minute after this token was issued, the app has no way to know until the token expires and the user re-authenticates.
  • Good fit for: read-mostly requests, UI rendering, anything where being a few minutes stale is an acceptable risk.
  • org_id is which one Organization context this particular launch resolved through (or null for a personal, non-org-sourced launch) — not a list of every Organization the user belongs to. See Domain Model → Users & Organizations → OrganizationMembership for why a User can hold more than one.

Deliberately absent from this claim: anything about Tenant placement (tier/region). Tenancy is set once per Application by its owner, not computed per end-user per request — an app that wants to know its own placement reads it from GET /v1/applications/{id} or GET /v1/tenants/{id}, not from a launch token. See Decisions → Tenancy tiers.

2. Live introspection

GET /v1/users/{id}/applications/{appId}/effective-permissions

Returns the current, authoritative answer — same shape as the JWT claims, but computed fresh. Good fit for:

  • Anything before a destructive or sensitive action.
  • The moment right after an admin says “I just turned this off” and needs it to actually be off, not off-in-five-minutes.
  • Apps that don’t want to manage JWT verification at all and are fine with a network round trip per check (with normal caching discipline on their side).

3. Webhooks for push-based revocation

Subscribe to entitlement.revoked, entitlement.disabled, role.removed (see Webhooks) to kill an active session the moment access changes, instead of waiting for a JWT to expire or polling introspection. This is the only one of the three mechanisms that achieves near-immediate revocation without the app checking on every request.

How fast does revocation need to land?

Resolved: immediately — see Decisions → Session model for revocation. This is not a per-Application choice between approaches; it’s a single global requirement every Application must meet, which makes the webhook path required integration, not an option for the compliance-sensitive minority:

Approach Revocation latency Status
JWT only, short TTL Up to one TTL window Not sufficient on its own — a live session surviving for the length of a TTL window after revocation doesn’t meet “immediately.”
JWT + webhook-driven session kill Near-immediate Required. The JWT TTL is the backstop for the gap between an event firing and the app acting on it, not the primary revocation mechanism.
Introspection on every sensitive action Immediate, for the actions it guards Recommended in addition, for destructive actions specifically — same as before, still the right belt-and-suspenders check immediately before something irreversible.

Concretely: every Application must subscribe to entitlement.revoked, entitlement.disabled, and role.removed (Webhooks) and force-expire the affected session the moment one arrives — not “may, if compliance-sensitive.” JWT TTLs should still be kept short (minutes), but short-TTL-alone is a degraded, non-compliant integration under this resolution, not a lighter-weight valid option.

What the hub guarantees, what it doesn’t

Guarantees: the hub is the only writer of Entitlement and Role state. effective_permissions, however computed (JWT claim or live call), always reflects the hub’s current records at the moment it was computed.

Doesn’t guarantee: that every app actually implements the required webhook-driven revocation correctly. The hub emits the event the moment access changes; it can’t force a third-party Application’s own code to act on it promptly, or at all. An Application that only relies on JWT expiry is out of compliance with Decision #6’s requirement, not exercising a lighter-weight valid option — but enforcing that compliance is an onboarding/review concern (see Decisions → App developer/publisher model), not something this API can verify at the protocol level.

Trust runs the other direction too

Everything above is about an app trusting the hub’s claims about a user. Once a third-party developer can register their own Application (see Decisions → App developer/publisher model), the hub also needs to limit how much it trusts the app:

  • An app-scoped API Key can never hold entitlements.manage, users.manage, or any other identity/access-control permission — only its own app.<slug>.* keys and read access implied by its own scope. A malicious or compromised third-party app’s key can corrupt data within its own app, never grant itself access to another app or escalate a user’s platform-wide standing.
  • An Application’s review_status (see Applications) gates whether it’s discoverable and launchable at all — pending_review keeps a newly self-registered app off the catalog until someone at Substratal looks at it.
  • The webhook signing secret (see Webhooks → Delivery) exists specifically so an app receiving a webhook can prove it came from the hub — the same mechanism, aimed the other way, is why the hub signs the launch JWT rather than just passing a bare user id.

None of this is new machinery bolted on for the marketplace phase — it’s why the API Key scoping and JWT/webhook signing were designed this way from the start, even while every Application is still first-party.


Back to top

Substratal Apps Platform API — living specification. This site is the system of record; see git history for how it has changed over time.

This site uses Just the Docs, a documentation theme for Jekyll.