Product documentation
Guest B2B collaboration
Invite an external email into a tenant as a scoped, time-limited guest: mint a single-use magic-link token, let the invitee redeem it (token possession is the authorization), provision a cross-tenant membership, and have the recorded tenant:* scopes reach the guest's access token through the same enrichment channel PIM and entitlements use. Grounded in the shipped product-api guest surface and the hub scope-union customizer.
Guest B2B collaboration
Guest collaboration lets a tenant admin invite an external person — someone with no standing
account in the tenant — into that tenant as a scoped, time-limited guest. The admin records
the tenant:* scopes the guest should hold; the platform mints a single-use magic-link token;
the invitee redeems it; and from then on the guest is a cross-tenant member whose recorded scopes
ride their access token, until the invitation's window lapses.
It is the guest-access counterpart to B2B Organizations: organizations model a tenant's own business customers as sub-entities; guest collaboration hands a specific outside individual a bounded foothold in the tenant — the "share this workspace with an external contractor for 30 days" shape.
The model has three moving parts:
- Invitation — a record that an external email may redeem into the tenant, carrying the
tenant:*scopes to grant, a single-use token (stored only as a hash), and an expiry. Created by an admin; grants nothing until it is redeemed. - Redemption — an authenticated but scope-gate-free call that turns a still-
PENDINGinvitation into an active cross-tenant membership for the redeeming subject, and stamps who redeemed it. Token possession is the authorization. - The token reach — a redeemed guest's recorded scopes are unioned into their access token's
scopeclaim the next time they mint a token, so a resource server's@PreAuthorize("hasAuthority('SCOPE_tenant:…')")actually passes. When the invitation's window lapses (or the guest is never redeemed), the next token no longer carries them.
Everything below is on the customer-plane management API (/api/v1/** on product-api, behind the
public api-gateway). For the admin operations the tenant is always taken from the tnt claim,
never from the path; request and response bodies are camelCase; errors are RFC 9457
problem-details (application/problem+json) with a stable errorCode; the list is
cursor-paginated; and cross-tenant (or cross-mode) access returns 404, never 403 — the
platform's no-existence-leak invariant. The one exception to the tnt rule is redemption, which is
deliberately tenant-agnostic — see Step 2.
The lifecycle at a glance
admin (tenant:guests.invite / .manage) invitee (any authenticated bearer)
────────────────────────────────────── ───────────────────────────────────
POST /guests/invite
external email + tenant:* scopes ───▶ single-use magic-link token (returned ONCE)
│ delivered to the invitee out of band
▼
POST /guests/redeem (token = the capability)
│ provision tenant_member in the INVITING tenant
▼ PENDING ─▶ ACCEPTED (stamps redeemed_by_subject)
next access-token mint: hub unions the ACCEPTED invitation's tenant:* scopes into `scope`
│
DELETE /guests/{id} (PENDING only) ─▶ REVOKED expiry sweep (~60s) ─▶ EXPIRED
│
a lapsed window (expires_at) drops the elevated scopes from the next mint;
the tenant_member login access is governed separately
Prerequisites
- A tenant-admin access token with the right scope for each admin step (see the table below).
tenant:guests.invitegates inviting;tenant:guests.managegates listing and revoking. These scopes ride under thetenant:namespace and are granted to your customer-plane client the same transitive "you can only delegate what you hold" way as every other tenant scope (see Authorize with roles, permissions, and relationships). - The invitee needs any authenticated Thoryn bearer to redeem — they do not need a scope, a membership, or a token in the inviting tenant. See the token-possession model in Step 2.
- For a redeemed guest's recorded scopes to actually reach their token, the client they sign in
through must have the
guestclaims-in-token source enabled (see Configuration). Without it, redemption still provisions the membership — only the scope-union is skipped.
Step 1 — Invite a guest (admin)
Inviting an external email is gated on tenant:guests.invite. The admin supplies the email and
the tenant:* scopes the guest should hold once they redeem.
curl -sS -X POST https://api.stg.thoryn.org/api/v1/guests/invite \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"email": "contractor@partner.example",
"scopes": ["tenant:reports.read", "tenant:documents.read"],
"ttlSeconds": 604800
}'email(required) — the address the invitation is for. Must contain an@; it is trimmed and lower-cased before storage. A blank or malformed value returns400 invalid_email.scopes(optional) — thetenant:*scopes the guest holds once the invitation is redeemed, recorded verbatim on the invitation. These should betenant:*scopes the tenant genuinely holds; the hub's namespace filter and the capability drop-gate are the backstops that keep a guest from ever carrying an operator-plane or non-entitled scope (see Step 3).ttlSeconds(optional, default604800= 7 days) — how long the invitation is valid; it sets the invitation'sexpiresAtand bounds the elevated-scope grant once redeemed.
On success you get 201 with the invitation record and the single-use token:
{
"id": "b6f1e2c0-1111-2222-3333-444455556666",
"invitedEmail": "contractor@partner.example",
"invitedBySubject": "user-42",
"status": "PENDING",
"scopes": ["tenant:documents.read", "tenant:reports.read"],
"maxTtlSeconds": 604800,
"expiresAt": "2026-08-10T09:41:22.170Z",
"createdAt": "2026-08-03T09:41:22.170Z",
"token": "9f8c…64-hex-chars…21a0"
}The token is a 32-byte cryptographically-random value, returned exactly once here. The
platform stores only its SHA-256 hash — the plaintext is never persisted and can never be retrieved
again. Embed it in the magic link you send to the invitee (for example
https://your-app.example/guest?token=…); your integration delivers that email, and the
invitee later posts the token back to the redeem endpoint.
Step 2 — Redeem the invitation (invitee)
Redemption is authenticated but scope-gate-free — it mirrors the B2B org-invitation accept
flow. There is no @PreAuthorize gate and no tnt requirement: the caller presents their own
bearer (from any tenant), which supplies the redeeming sub, and the single-use token in the
body is the capability.
curl -sS -X POST https://api.stg.thoryn.org/api/v1/guests/redeem \
-H "Authorization: Bearer $INVITEE_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "token": "9f8c…64-hex-chars…21a0" }'On success you get 200 with the now-ACCEPTED invitation. Two things happen, in this order, so a
failure leaves the invitation cleanly retryable rather than accepted-but-access-less:
- The redeeming subject is provisioned as an active
tenant_memberof the inviting tenant (TenantMemberService.admit, idempotent) — the membership axis the hub's login gate consults, so this is what actually grants the guest access to the tenant. - The invitation transitions
PENDING → ACCEPTED, stampingacceptedAtand the redeeming subject, and writing aguest.invite.acceptedaudit row carrying the granted scopes.
Why redemption is scope-gate-free — the token-possession model
A magic-link token is the authorization. The whole point of an emailed invitation is that it
can be forwarded: the person who ultimately redeems need not control the invitedEmail address
(a contractor forwards it to a colleague; a shared inbox routes it onward). So redemption
deliberately does not check that the caller's identity matches invitedEmail, and does not
require the caller to hold any scope — it authorizes on possession of the single-use token, and
provisions the membership in the invitation's tenant regardless of which tenant the caller's own
token belongs to. This is the same trust model the platform's B2B org-invitation accept uses.
The security that makes this safe lives in the token itself: it is high-entropy, single-use, stored only as a hash, and time-bounded (see Security notes).
Redemption outcomes:
200— redeemed; membership provisioned, scopes recorded against the redeeming subject.404 not_found— the token matched no row (never existed, or was already used).422 invitation_expired— the invitation was stillPENDINGbut past itsexpiresAt; the row is markedEXPIREDand no membership is provisioned.409 invitation_already_terminal— the invitation is alreadyACCEPTED/REVOKED/EXPIRED. This is the single-use guard: a second redemption of an already-accepted invitation short-circuits here and provisions nothing.
Step 3 — How a recorded scope reaches the access token
A redeemed guest is now a tenant_member, which grants them access to the tenant — but a
membership row carries no scopes, and an invitation record enforces nothing at a resource server.
The recorded tenant:* scopes start granting privilege only when they reach a live access
token. That bridge is the hub's ElevatedScopeCustomizer, wired into the token-mint customizer
chain (see the Token lifecycle architecture page). It is
the same shared wire PIM and entitlement packages ride — the design is recorded in ADR
2026-08-03-pim-entitlement-token-enrichment.md (SSO-1321 / SSO-1322), extended to the guest
source in SSO-2276.
On every access-token mint — a fresh login or a refresh — for a client whose tenant enabled
the guest source:
- The hub calls
product-api's in-cluster enrichment endpointPOST /internal/claims-enrichment(HMAC-signed, off the JWT-signing thread — the same bounded, IO-dispatched transport the claims-enrichment webhook uses, peradrs/2026-07-02-token-claims-enrichment-auto-wire.md). product-apiresolves the subject's in-force redeemed-guest scopes (ElevatedScopeResolver→GuestService.getActiveScopes, cached in Redis for a short TTL) and returns them on the response'sscopeschannel — the de-duplicated union of the recorded scopes of every currently-in-forceACCEPTEDinvitation the subject redeemed (a guest re-invited and re-redeemed can hold more than one). The resolver filters onexpires_at > now, so a lapsed invitation contributes nothing.- The hub filters that set to the
tenant:*namespace (ElevatedScopeExtractor— a hard security floor) and unions the survivors into the access token'sscopeclaim. - The capability-entitlement gate then runs as a backstop, still dropping any
tenant:<family>.*scope whose family the tenant is not entitled to (peradrs/2026-06-17-tenant-capability-entitlement.md).
The guest's next access token now carries the recorded scopes, and the matching
@PreAuthorize("hasAuthority('SCOPE_tenant:…')") checks pass.
Two consequences worth internalising:
- The guest needs a fresh token. The scope-union is applied at mint time; a token the guest already holds does not retroactively gain the scopes. Re-authenticate or refresh after redeeming.
- Access token only. Scopes are authorization, not identity — a guest's recorded scope is never put on the ID token or the refresh token.
Step 4 — Expiry and revocation
Guest access is designed to be time-bounded, and the two governed axes — the elevated scopes and the membership/login access — expire differently:
- The scopes are bounded by the invitation window.
GuestService.getActiveScopesfilters onexpires_at > now, so once anACCEPTEDinvitation's window lapses its recorded scopes stop resolving into new tokens — the guest's next mint simply no longer carries them (the under-privilege direction). No sweep is required for this; it is a real-time read filter. - Auto-expiry of unredeemed invitations.
GuestExpiryTaskruns a sweep every 60 seconds, transitioning any still-PENDINGinvitation whoseexpiresAthas passed toEXPIREDand writing an audit row, so a never-redeemed invitation cannot be redeemed after its window. - Revocation cancels a pending invitation.
DELETE /api/v1/guests/{id}(scopetenant:guests.manage) transitions aPENDINGinvitation toREVOKEDso it can no longer be redeemed. Revoking an already-terminal invitation (ACCEPTED/REVOKED/EXPIRED) returns409 invitation_already_terminal; an id not in the caller's tenant (or mode) returns404.
See Limitations for what revocation does not do to an already-redeemed guest.
Endpoint reference
| Method | Path | Scope | Description |
|---|---|---|---|
POST | /api/v1/guests/invite | tenant:guests.invite | Invite an external email as a scoped guest; returns the single-use token |
POST | /api/v1/guests/redeem | bearer, no extra scope | Redeem an invitation token (token possession authorizes) |
GET | /api/v1/guests | tenant:guests.manage | List the tenant's invitations (all statuses), cursor-paginated |
DELETE | /api/v1/guests/{id} | tenant:guests.manage | Revoke a PENDING invitation |
All four are exposed through the public gateway on the full versioned path (the product-api-guests
route in api-gateway/application.yml, forwarded unstripped). The generated, always-current
contract for these endpoints is in the API reference: Guest.
Configuration
Enable the guest enrichment source on the client. The scope-union call only fires for a client
whose tenant has turned on the guest claims-in-token source — the same per-client toggle that
gates the claims channel and the pim / entitlements scope sources. Enable it through the
client's claims-in-token configuration; see Claims in the token. Without it,
invitations still redeem and the membership is still provisioned, but no recorded scope reaches the
token.
Invitation TTL. The invite's ttlSeconds sets the invitation's lifetime and defaults to
604800 (7 days). It bounds both how long the invitation may be redeemed and — once redeemed — how
long the recorded scopes resolve into new tokens.
Enrichment cache TTL. The redeemed-guest scopes are read through a short-TTL Redis cache
(ElevatedScopeCache, under its own guest keyspace segment) on the access-token mint hot path,
keyed on (tenant, subject, mode). A just-redeemed grant appears within that TTL; a just-lapsed one
lingers at most that long. This is the same bounded cache the PIM and entitlement scope lookups use
— see the hub token-claims configuration in the
PIM guide.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
400 invalid_email on POST /invite | email is blank or has no @ | Supply a valid email address |
401 missing_tnt on invite / list / revoke | The admin token has no valid tnt claim | Use a tenant-admin token minted for the tenant |
404 not_found on POST /redeem | The token matched no row — it never existed, or was already used | Confirm the exact single-use token from the invite response; a used or superseded token will not redeem |
422 invitation_expired on POST /redeem | The invitation was still PENDING but past its expiresAt | The admin must send a fresh invitation |
409 invitation_already_terminal on POST /redeem | The invitation is already ACCEPTED / REVOKED / EXPIRED | Single-use: re-invite if the guest needs access again |
409 invitation_already_terminal on DELETE /{id} | The invitation is already ACCEPTED / REVOKED / EXPIRED | Only a PENDING invitation can be revoked (see Limitations for accepted guests) |
Guest redeemed OK but a resource server still returns 403 | The guest holds the pre-redemption token; the client's guest source is off; the cache TTL has not elapsed; the tenant is not entitled to that scope family; the invitation window has lapsed; or the token's mode (test/live) differs from the invitation's | Mint a fresh token (re-login or refresh); enable the guest source; wait out the cache TTL; check the tenant's capability entitlement; confirm the invitation is still in force; match the mode |
404 on redeem/revoke/list for an id or token from elsewhere | The row belongs to another tenant or another mode | Expected — cross-tenant/cross-mode access is 404 by design, not 403 |
Security notes
- Single-use magic-link tokens. The redemption token is a 32-byte CSPRNG value; the database
stores only its SHA-256 hash, so a read of the database never yields a usable token. The plaintext
is returned exactly once (on invite) and never again. Redemption is single-use — a second attempt
on an already-
ACCEPTEDinvitation409s and provisions nothing. - Token-possession authorization. Redemption is authenticated but scope-gate-free by design: the
bearer supplies the redeeming
sub, and the token is the capability. Magic links are forwardable on purpose — the redeemer need not owninvitedEmail, and the membership always lands in the invitation's tenant, never the caller's owntnt. The token's entropy, single-use nature, and expiry are what bound this — the same trust model as the B2B org-invitation accept. - Expiry-bounded scopes, fail-safe. The recorded scopes reach the token only while the
invitation's window is in force (
expires_at > now); a lapsed invitation drops the elevated scopes from the next mint (the under-privilege direction). And because the scope-union path only adds privilege, it is deliberately fail-open: any enrichment failure — timeout,product-apidown, non-2xx, malformed body — issues the token without the guest scopes. A failure makes the guest under-privileged (a403they retry), never over-privileged, and never breaks token issuance or login. - Membership and scopes are separate axes. Redemption provisions a
tenant_member(the login axis) and records scopes (the authorization axis). The invitation window governs the elevated scopes; thetenant_memberlogin access is governed separately and is not dropped by invitation expiry — see Limitations. - Namespace floor and capability backstop at the hub. The hub filters the enrichment response to
tenant:*before the union (ElevatedScopeExtractor), so even a buggy or compromisedproduct-apicannot inject an operator-plane (admin:*) scope into a guest's access token. The capability drop-gate is a further backstop, so a guest can never carry atenant:<family>.*scope past the tenant's own capability entitlement. - Tenant, mode, and environment isolation, full audit. Every admin operation is scoped by the
tntclaim, the token's test/live mode, and the caller's resolved environment (the customer-plane list and revoke reads AND the invitation'senvironment_id— the workspace/environment model, epic SSO-2408); cross-tenant / cross-mode / cross-environment reads return404, never403. Every transition (invite, accept, revoke, expire) writes an audit row under theguestretention category — a test-mode invitation's trail stays in the test dataset.
Limitations
- Revocation is
PENDING-only.DELETE /api/v1/guests/{id}cancels an invitation that has not yet been redeemed; it returns409 invitation_already_terminalfor an already-ACCEPTEDinvitation and does not remove an already-redeemed guest'stenant_memberor their in-force scopes. To time-bound an accepted guest's elevated scopes today, rely on the invitation'sexpires_atwindow (after which the scopes stop resolving into new tokens). Removing thetenant_memberlogin access itself is a separate concern not covered by this endpoint — the SSO-1324 scope boundary; a future story may add accepted-guest revocation.
See also
- Entitlement access packages and
Just-in-time privileged elevation (PIM) — the sibling features
that ride the same enrichment wire to reach the access-token
scopeclaim. - B2B Organizations — modelling a tenant's own business customers; the org-invitation accept flow guest redemption mirrors.
- Token lifecycle — where
ElevatedScopeCustomizersits in the mint-time customizer chain. - Claims in the token — enabling the per-client enrichment sources, including
guest. - Guest API reference — the generated endpoint contract.