JWT
Every request to every service is authorised by a JWT. It is minted by the identity API and carries everything a downstream service needs to decide what the caller may do, so no service has to ask another who is calling.
Claims
{
"iss": "auth@tagshape.com",
"sub": "9c1e7a4d-3f52-4a18-9b6c-2d0e5f7a1b83",
"aud": "tagshape.com",
"nbf": 0,
"exp": 1786000000,
"iat": 1785996400,
"jti": "b2f1c8d0-…",
"name": "Neil Docherty",
"email": "neil@example.com",
"org": "05cc0c65-021c-4515-850f-366667e26656",
"permissions": [769, 1537, 1794],
"librarySetScopes": [{ "permission": 1794, "variableSetIds": [12, 47] }]
}
| Claim | Meaning |
|---|---|
iss | Issuer. Configured; defaults to auth@tagshape.com. |
sub | The TagShape account ID, not the provider's identifier for the user. |
aud | Audience. Configured; defaults to tagshape.com. |
exp / iat / nbf / jti | Standard expiry, issued-at, not-before and token ID. |
name, email | Taken from the account where one exists, falling back to what the provider said. |
org | The organization the session belongs to. Scopes every query in every service. |
permissions | The numeric Permission values the caller holds, gathered from their groups. |
librarySetScopes | Narrows a subset of permissions to specific library sets. Omitted entirely when nothing is scoped — see below. |
permissions is an array of the enum's integer values — see the
permissions catalogue for the mapping. It is a flat list of what the caller
was actually granted; dependency expansion happens at check time, not at mint time.
librarySetScopes
Each entry narrows one library-set-scopable permission from
permissions down to specific library set IDs. A permission from permissions with no matching entry here
applies to every library set in the organisation — the default, and the only behaviour before this claim
existed.
{ "permission": 1794, "variableSetIds": [12, 47] }
permission is the same integer permissions uses; variableSetIds are VariableSet.Id values from the
library-set API, meaningful only within the caller's own organisation.
Omitted, not empty, when nothing is scoped. An account holding only unrestricted grants mints a token with
no librarySetScopes claim at all — byte-for-byte what it minted before this claim existed. It is computed by
flattening every group the account belongs to: if any group grants a permission unrestricted, the whole
account holds it unrestricted, whatever another group narrows it to. Only when every granting group scopes
a permission does the claim carry it, as the union of every group's library sets for it.
Edge cases in the claims
- An account with no permissions still gets a token, with an empty
permissionsarray. It is logged as a warning. Every permissioned endpoint will refuse it. - A login with no account (during provisioning) gets
subas the empty GUID rather than pointing at somebody else's account. - Name and email prefer the account over the provider. A rename reaches the token on the login that noticed it, rather than the one after.
Lifetime
| Setting | Default |
|---|---|
DurationInMinutes | 60 |
RefreshDurationInMinutes | 10080 (7 days) |
Configured under Jwt — see Configuration.
Signing
RS256, with an asymmetric key pair. The private key is configuration on the identity API
(Jwt:PrivateKey); the corresponding public key is what verifies tokens.
GET /generatejwk/{key} on the identity API generates a pair — public PEM, private PEM and JWK. It is an
operational tool run out of band, not part of the request path.
Minting
From a login
{
"userId": "…",
"provider": "Entra",
"providerUserId": "…",
"name": "Neil Docherty",
"email": "neil@example.com",
"orgId": "05cc0c65-021c-4515-850f-366667e26656",
"profileImage": null
}
Returns the token as plain text. Two failure modes are distinguished:
| Status | Meaning |
|---|---|
403 Forbidden | There is nobody to issue a token to and no pattern permits creating them. Not a fault in the request — the caller should end the session rather than report a problem with it. |
400 Bad Request | The request itself was wrong. |
From an API key
POST /apikeyjwt, with the key in x-tagshape-api. See API keys.
Renewal
{
"userId": "…",
"orgId": "…"
}
Renewal re-reads the account's permissions from GET /users/{id}/permissions before minting, so a permission
granted or withdrawn takes effect at the next refresh rather than at the next full login. It deliberately
creates nothing and changes nothing — provisioning only ever happens on a login.
That endpoint takes an ID rather than an email address because a renewal knows the account it is for, not the provider identity that started the session.
The management UI runs a background refresh against this, which is why the token also travels as a cookie.
Reading the token in a service
IJwtPayload, registered per request, reads the token from the header or the cookie and exposes UserId,
OrgId, Email, Name, Permissions, LibrarySetScopes and JwtFound. Anything that cannot be read leaves
JwtFound false with a FailureMessage, and every permission check fails closed.
public MyEndpointHandler(IJwtPayload jwtPayload)
{
var organizationId = jwtPayload.OrgId; // scope every query by this
var canSeeSecrets = jwtPayload.HasPermissions([Permission.VariableReadSecrets]);
// The resource-level counterpart, once a specific library set is in hand:
var canSeeThisSecret = jwtPayload.HasPermissionForLibrarySet(Permission.VariableReadSecrets, variableSetId);
}
Most requests — static files, health checks, anonymous endpoints — carry no permission requirement at all, and the middleware short-circuits before reading the token so those do not pay for it.