1. auth
temper
  • Temper documentation
  • 2026-07-06-cogmap-telos-differentiation
  • handoffs
  • plans
    • archived
    • evidence
  • reviews
  • specs
  • spikes
  • agents
  • auth
    • Temper auth & security
    • The authorization seam (`temper-services::auth`)
    • Cognitive-map & resource authoring authorization
    • JWT verification
    • The issuer / resource-server boundary & the machine-token contract
    • The internal reconcile channel
  • code-reviews
  • cognitive-maps
  • decisions
  • development
  • guides
    • Building a cognitive map from a large corpus
    • Using temper from Claude Desktop (and claude.ai)
    • Cloud Agent Development Guide
    • Ingesting a corpus into a context
    • Development setup
    • Drain operator queries (TraceQL)
    • Enterprise Install — Ground Up
    • Operator guide: read-only GitHub credential via a BYO App + Vercel Connect
    • Operator guide: provisioning a GitHub connection in temper
    • Installing Temper
    • Delivering L0 Kernel Cogmap Content
    • Machine Credentials
    • OpenTelemetry Setup
    • Operational Memory
    • Bootstrapping a Temper org
    • Releasing Temper
    • Self-Hosting Temper with Okta
    • Self-Hosting Temper with a SAML IdP
    • Self-Hosting Temper
    • The Slack Integration — identity, credentials, and what revocation actually means
    • Operator guide: `@temper` on Slack — end-to-end setup
    • Bootstrapping a team's self-cognition cognitive map
    • Working with Teams
    • Integrating Ruby with temper-rb
    • Deploying an Eve agent to Vercel (the steward and the citation auditor)
    • WS6 Collapsed-Schema Dev Environment
  • research
  • security
  • specs
  • doors
    • Building against Temper
    • Running Temper
    • Using Temper
  • Temper /api/query — the compositional read surface
    • Run a declared composition of situated acts.
    • Schemas
      • Composition
      • OutcomeDeclaration
      • ReturnSpec
      • Intention
      • StageNode
      • ActInvocation
      • CombineNode
      • StageInput
      • StageRelation
      • Disclosure
      • QueryResponse
      • StageResult
      • StageOutput
      • ScoreKind
      • Scoring
      • ResourceHit
      • CompositionTrace
      • StageTrace
      • InputSource
      • NarrowedBy
      • StageName
      • ActName
      • IdKind
      • IdSet
      • BoundTerm
      • Extent
      • StageDisposition
      • ActRefusal
      • PlanRefusal
      • RefusalReason
      • ActQuantity
      • QuantityScale
      • ResourceFilter
      • EdgeFilter
      • PropertyPredicate
      • PropertyOp
      • ResourceSection
      • EdgeKind
      • OrdOp
      • CogmapId
      • ContextId
      • IdProvenance
      • FacetPredicate
      • CombineOp
      • BlockId
      • MatchLocation
      • BodyStorage
      • ResourceId
      • IngestState
      • ManagedMeta
      • ProfileId
      • ResourceView
      • Polarity
      • ViaEntry
      • LensId
      • RegionId
      • CogmapRegionRow
      • RegionHit
      • StageInputTrace
      • Standing
      • ActorAuthority
      • Refusal
      • SystemAccessDetails
      • PlanRefusalDetails
      • ErrorDetails
      • ErrorDetail
      • ErrorBody
  • Temper Cloud API
    • Access
      • POST /api/access/requests — submit a join request for the gating team.
      • GET /api/access/requests/me — check own join request status.
      • DELETE /api/access/requests/me — withdraw a pending join request.
      • POST /api/access/reviews — a revoked principal asks an admin to reconsider (spec D15).
      • GET /api/access/settings — read public system settings.
    • Slack Link
      • Disconnect any principal. Operator path — offboarding and stuck users.
      • Disconnect EVERY Slack principal bound to the caller's own profile.
    • Auditor
      • auditor_dispatch
      • auditor_sweep
      • complete_auditor_job
    • Graph
      • POST /api/cogmaps/{id}/graph/slice — R4 cogmap-scoped neighborhood slice.
      • GET /api/graph/cogmaps/{id}/panorama — enter-a-cogmap Tier-0 interior.
      • GET /api/graph/contexts/composition — Beat E Tier-1: the force-graph composition of a container's (or a residual bucket's) members.
      • GET /api/graph/contexts/panorama — Beat E Tier-0: goal-container territories + residual tray.
      • GET /api/graph/home — the you→teams→cogmaps membership home.
      • GET /api/graph/regions/composition — Beat D region→resources composition drill.
    • Cognitive Maps
      • list_cognitive_maps
      • genesis
      • get_cognitive_map
      • reconcile
      • analytics
      • grant_cogmap_access
      • revoke_cogmap_access
      • materialize
      • materialize_delta
      • region_metrics
      • shape
      • bind_team
      • unbind_team
    • Contexts
      • list_contexts
      • create_context
      • get_context
      • context_materialize
      • reassign
      • context_region_metrics
      • rename
      • context_shape
      • share_team
      • unshare_team
    • Events
      • cursor
      • GET /api/graph/elements/{kind}/{id}/trail — R5 element event-trail. kind ∈ {node, edge}.
    • Facets
      • set_facet
      • Read the live facets of one edge.
      • Set a facet whose owner is an **edge** rather than a resource.
      • Read the live facets of one resource — the confirming read for a write that steers region formation and Atlas grouping.
    • Health
      • health_check
    • Ingest
      • create_ingest
      • update_ingest
      • list_blocks
      • append_block
      • finalize_resource
    • Invitations
      • accept
      • decline
      • list_mine
      • list_team_invitations
      • revoke_team_invitation
      • create_team_invitation
    • Invocations
      • list_invocations
      • open
      • show
      • close
    • Profile
      • get_profile
      • update_profile
      • list_auth_links
    • Query
      • `POST /api/query`.
    • Relationships
      • assert
      • fold
      • retype
      • reweight
    • Resources
      • `GET /api/resources` — **one response type, unconditionally.**
      • create_resource
      • get_resource
      • delete_resource
      • update_resource
      • List the finding at `{id}`'s citation-audit trail — one row per audit, each naming its auditor.
      • Record an auditor's signed defensibility verdict on one `(block, source)` citation of the finding at `{id}`. CONFORM to `handlers::edges::assert` (the sibling authored-write handler): thin — build the command, dispatch it, map the error. No persistence he
      • get_content
      • list_resource_edges
      • resource_evidence
      • grant_resource_access
      • revoke_resource_access
      • resource_lineage
      • provenance
      • annotate_resource
    • Meta
      • get_meta
      • update_meta
    • Reassign
      • reassign_resource
      • reassign_team
    • Search
      • `POST /api/search`.
    • Steward
      • candidates
      • dispatch
      • sweep
      • delta
      • advance
    • Teams
      • list_teams
      • create_team
      • detail
      • delete_team
      • update_team
      • add_member
      • remove_member
      • change_role
    • Schemas
      • AcceptInvitationResponse
      • ActInput
      • ActInvocation
      • ActName
      • ActQuantity
      • ActRefusal
      • ActorAuthority
      • AddMemberRequest
      • AdvanceWatermarkAck
      • AdvanceWatermarkRequest
      • AgentAuthorship
      • AppendBlockPayload
      • AssertRelationshipRequest
      • AtlasEdge
      • AtlasHome
      • AtlasNode
      • AtlasSubgraph
      • AuditCitation
      • AuditSweepRow
      • AuditorDispatchTickRequest
      • AuditorDispatchTickResponse
      • AuditorJobCompleteAck
      • BindTeamOutcome
      • BindTeamRequest
      • BlockId
      • BlockProvenanceRow
      • BlocksResponse
      • BodyStorage
      • Bridge
      • BulkReassignAck
      • BulkReassignRequest
      • ChangeRoleRequest
      • CharterBlock
      • CharterDisposition
      • CitationAuditRequest
      • CitationAuditRow
      • ClaimedAuditJob
      • ClaimedJob
      • CloseInvocationRequest
      • CogmapAnalyticsRow
      • CogmapDetail
      • CogmapFoundationRow
      • CogmapGrantBody
      • CogmapId
      • CogmapRegionMetricsRow
      • CogmapRegionRow
      • CogmapRegulationRow
      • CogmapRevokeBody
      • CogmapRow
      • CogmapStaleness
      • CombineNode
      • CombineOp
      • Composition
      • CompositionTrace
      • ConfidenceBand
      • ContentResponse
      • ContextCreateRequest
      • ContextId
      • ContextOwnerRef
      • ContextPanorama
      • ContextRow
      • ContextRowWithCounts
      • CorrelationId
      • CreateCogmapOutcome
      • CreateCogmapRequest
      • CreateInvitationRequest
      • CreateRequestBody
      • CreateReviewBody
      • DeleteResponse
      • DeviceOverrides
      • DispatchTickRequest
      • DispatchTickResponse
      • Disposition
      • DriftSweepRow
      • EdgeFacetRow
      • EdgeFacetSetRequest
      • EdgeFacetsResponse
      • EdgeFilter
      • EdgeId
      • EdgeKind
      • ElementEvent
      • ElementKind
      • EmbedDispatchSummary
      • Entitlements
      • ErrorBody
      • ErrorDetail
      • ErrorDetails
      • EventCursorResponse
      • EventTrail
      • ExactArm
      • ExactHit
      • Extent
      • FacetAck
      • FacetPredicate
      • FacetSetRequest
      • FinalizePayload
      • FoldRelationshipRequest
      • GrantOutcome
      • GraphEdgeRow
      • GroupKeyMeta
      • HealthResponse
      • HomeCogmap
      • HomeContext
      • IdKind
      • IdProvenance
      • IdSet
      • IdpRevocation
      • IngestCreateResponse
      • IngestDelta
      • IngestPayload
      • IngestState
      • InheritedReadGrant
      • InheritedShare
      • InputSource
      • Intention
      • InvitationStatus
      • InvitationTokenRequest
      • InviteeInvitation
      • InvocationAck
      • InvocationActRow
      • InvocationCloseAck
      • InvocationId
      • InvocationSummary
      • InvocationView
      • JoinRequest
      • JoinRequestStatus
      • LensId
      • LineageNode
      • ManagedMeta
      • MatchLocation
      • MaterializeAck
      • MaterializeDelta
      • MaterializeRequest
      • MergePolicy
      • MetaUpdatePayload
      • NarrowedBy
      • NodeHome
      • OpenInvocationRequest
      • OrdOp
      • OrphanNode
      • OutcomeDeclaration
      • PlanRefusal
      • PlanRefusalDetails
      • Polarity
      • Profile
      • ProfileAuthLink
      • ProfileId
      • ProfileUpdateRequest
      • ProfileWithEntitlements
      • PropertyOp
      • PropertyPredicate
      • ProvenanceSource
      • PublicSystemSettings
      • QuantityScale
      • QueryResponse
      • ReassignAck
      • ReassignContextOutcome
      • ReassignContextRequest
      • ReassignResourceRequest
      • ReconcileCogmapRequest
      • ReconcileEdge
      • ReconcileEdgeTombstone
      • ReconcileEntry
      • ReconcileOutcome
      • ReconcileTelos
      • ReconcileTelosBlock
      • ReconcileTombstone
      • Refusal
      • RefusalReason
      • RegionHit
      • RegionId
      • RelationshipAck
      • RemoveMemberOutcome
      • RenameContextOutcome
      • RenameContextRequest
      • ResidualBucket
      • ResidualContext
      • ResidualGroups
      • ResidualOwnedReach
      • ResourceAnnotateRequest
      • ResourceCreateRequest
      • ResourceFacetRow
      • ResourceFacets
      • ResourceFacetsResponse
      • ResourceFilter
      • ResourceGrantBody
      • ResourceHit
      • ResourceId
      • ResourceLineage
      • ResourceListResponse
      • ResourceRevokeBody
      • ResourceSection
      • ResourceSortField
      • ResourceUpdateRequest
      • ResourceView
      • ReturnSpec
      • RetypeRelationshipRequest
      • RevokeOutcome
      • ReweightRelationshipRequest
      • ScoreKind
      • Scoring
      • SearchParams
      • SearchReason
      • SearchResponse
      • SearchResultRow
      • SearchScope
      • SearchScopeInfo
      • SegmentInfo
      • SegmentedBegin
      • SegmentedBeginResponse
      • ShareContextOutcome
      • ShareContextRequest
      • SlackDisconnectRequest
      • SlackDisconnectResponse
      • SlackDisconnectedPrincipal
      • SliceRequest
      • SortOrder
      • StageDisposition
      • StageInput
      • StageInputTrace
      • StageName
      • StageNode
      • StageOutput
      • StageRelation
      • StageResult
      • StageTrace
      • Standing
      • StandingShape
      • Subscription
      • SubscriptionOverride
      • SystemAccessDetails
      • TeamCreateRequest
      • TeamDetail
      • TeamInvitation
      • TeamMemberDetail
      • TeamMemberRow
      • TeamMemberSource
      • TeamRole
      • TeamRow
      • TeamUpdateRequest
      • Territory
      • TerritoryKind
      • TerritoryOverview
      • UnbindTeamOutcome
      • UnshareContextOutcome
      • VaultConfig
      • ViaEntry
      • WideArm
      • WideHit
  1. auth

JWT verification

Both surfaces verify a Bearer JWT before anything reaches the authorization
seam
. Verification stays per-surface — and it is now the only
thing that does, because the audience differs legitimately. The shared machinery is the
JwksKeyStore; everything downstream of the decode (classification, the email ladder, claim
construction, the gates) is the seam's.
Source: crates/temper-services/src/state.rs (JwksKeyStore),
crates/temper-api/src/middleware/auth.rs, crates/temper-mcp/src/middleware.rs.

Two issuers, one verifier#

An instance validates tokens from exactly one issuer, configured by env:
Auth0 / OIDC (temperkb.io and Okta-fronted self-hosting). Tokens are RS256.
JwksKeyStore fetches the RSA public key from Auth0's JWKS.
Temper Authorization Server (native SAML self-hosting). The AS mints EdDSA
(Ed25519) tokens; JwksKeyStore fetches the OKP key from the AS's /oauth/jwks.
See ../guides/self-hosting-saml.md for how the AS is
stood up.
Both issuers mint human and machine tokens, with the same signing key per issuer — a
client_credentials token is not a separate key family, only a separate claim shape. So
verification is identical for both and the split happens one step later, in the seam's
classifier.
JwksKeyStore supports both key families and maps each to its algorithm:
Single-family validation allow-list. jsonwebtoken's verify_signature rejects any
Validation whose algorithm allow-list contains a family the loaded key does not match. So
the algorithm must travel with the key: get_decoding_key() returns a VerificationKey { key, algorithm }, and validation(issuer, audience, algorithm) scopes the allow-list to
exactly that one algorithm. This is why the store returns the algorithm rather than letting
the caller guess it.
The JWKS is cached with a 1-hour TTL (JwksKeyStore::new); tests preload a static key via
with_static_key.

One audience, both surfaces#

There used to be a per-surface audience split here: temper-api validated config.auth_audience
while temper-mcp validated mcp_config.mcp_audience, parsed separately from MCP_AUDIENCE with a
fallback to AUTH_AUDIENCE. That is gone.
issueraudience
temper-apiconfig.auth.issuerconfig.auth.audience
temper-mcpconfig.auth.issuer (same)config.auth.audience (the same)
Both call jwks_store.validation(issuer, audience, alg) — note audience: &str, not
Option<&str>. An instance has exactly one audience, parsed once at boot.
Two parsers for one concept is precisely how the surfaces came to disagree: an empty
AUTH_AUDIENCE made temper-api set validate_aud = false and accept everything, while an empty
MCP_AUDIENCE made temper-mcp enforce aud == "" and reject everything. One typo, two opposite
failures, neither of them anyone's decision.
set_audience is not sufficient on its own. jsonwebtoken only compares the audience when
the aud claim is present — required_spec_claims defaults to {"exp"}. A token omitting
aud entirely was accepted even with validate_aud = true. validation() therefore sets
required_spec_claims(&["exp", "iss", "aud"]). Requiring the value to match without requiring
the claim to exist closes half a door.

What the surface hands the seam#

Verification produces raw JWT claims. The surface decodes them into the shared
temper_services::auth::RawJwtClaims — a superset struct whose optional fields (email,
email_verified, azp, gty) absorb the human/machine shape difference — and hands the seam
two things:
RawJwtClaimsthe decoded claims, exactly as verified
the raw bearer &strneeded by one rung of the email ladder — the /userinfo call presents the token itself, not its claims
That is the whole handoff: authenticate_token(&state, &raw, token). The surface does not
build an AuthClaims — the seam is the only constructor. On temper-mcp the two values travel
through the HTTP extensions as RawJwtClaims + BearerToken (a newtype, so it cannot be
confused with any other string in the extensions map).

The email-resolution ladder (in the seam, for both surfaces)#

crates/temper-services/src/auth/email.rs. It used to live in temper-api's middleware, and
only there — temper-mcp set email: String::new() and auto-provisioned. It now runs for
whichever surface presented the token, and resolves in order:
1.
the email claim embedded in the token (a custom Auth0 Action can add it), else
2.
a previously cached email in kb_profile_auth_links (from a prior login), else
3.
the OIDC /userinfo endpoint (discovered once per process via
/.well-known/openid-configuration) as a last resort.
Falling off the bottom is AuthzError::EmailResolution → a 401 (HTTP) / a terminal
INVALID_REQUEST (MCP): a human we cannot name is a human we will not provision. The
ladder is deliberately concrete rather than a trait — there is exactly one implementation and
no policy to vary, and a surface that wanted a different email answer would be re-introducing
the drift.
The ladder is on the human arm only. A machine token has no human email and no
/userinfo to ask, so running it on that path would be an authentication failure dressed as a
lookup — see the machine-token contract.

Instance-mode invariants#

These are no longer advice. They are enforced at boot by parse_auth_config
(temper-services/src/auth_config.rs) — an instance that violates any of them refuses to start,
naming the variable and the relation it must satisfy. They used to be operator discipline, which is
how they came to be violated silently.
One issuer per instance. An instance is either an AS/SAML instance (AS_ISSUER set,
EdDSA) or an Auth0/OIDC instance (RS256) — never both. Setting AS_ISSUER flips the
instance into AS mode (AuthMode::TemperAs).
AUTH_AUDIENCE is mandatory. Empty counts as unset. It used to resolve to None, which set
validate_aud = false and disabled audience validation outright; there is no longer an
Option to carry that state.
AS↔API shared values must agree. AS_AUDIENCE == AUTH_AUDIENCE, AS_ISSUER == AUTH_ISSUER,
and JWKS_URL == $AS_ISSUER/oauth/jwks (trailing slashes normalized before comparison).
temper admin saml provision keeps them consistent by construction. Details in
../guides/self-hosting-saml.md.
An instance has exactly ONE audience. temper-mcp no longer carries its own — the
mcp_audience field is gone, and both surfaces read AuthConfig::audience. MCP_AUDIENCE
survives as an env var, but purely as an assertion: if set, it must equal AUTH_AUDIENCE, or
the instance does not boot. There was previously an MCP_AUDIENCE ?? AUTH_AUDIENCE fallback, and
two parsers for one concept is precisely how the surfaces came to answer an empty value in
opposite ways — temper-api fell open, temper-mcp fell shut.
This matters most on an AS instance: the Temper AS mints every token — human and machine —
with the server-side AS_AUDIENCE, ignoring any request-supplied audience (mint.ts). There is
no way to ask it for a differently-audienced token, so a divergent MCP_AUDIENCE would make
AS-minted tokens unverifiable at /mcp. That is now unreachable rather than merely discouraged.
Modified at 2026-08-19 17:59:52
Previous
Cognitive-map & resource authoring authorization
Next
The issuer / resource-server boundary & the machine-token contract
Built with