Trust Model
How a separately-hosted app verifies a user’s identity and access without maintaining its own user table.
- Why apps shouldn’t own their own user table
- 1. Short-lived JWT at launch
- 2. Live introspection
- 3. Webhooks for push-based revocation
- How fast does revocation need to land?
- What the hub guarantees, what it doesn’t
- 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.
expshould 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_idis which one Organization context this particular launch resolved through (ornullfor 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 ownapp.<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_reviewkeeps 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.