Identity Flow
How a signed-in user's identity reaches your Grove action: from the browser's ID-token cookie, through the edge worker that validates it and stamps identity headers, across an HMAC-signed trust boundary, to
ctx.principalin yourauthorizehook and action body.
Prerequisites: Authentication Overview.
What you'll learn: The headers the edge sets on every authenticated request, why your backend can trust them, and how they populate ctx.principal.
The end-to-end flow
A typical authenticated request traverses four places:
Browser ──cookie──▶ Edge worker ──HMAC-signed HTTP──▶ Grove backend ──▶ Action
(validates JWT, (verifies HMAC,
extracts claims) populates ctx.principal)
The browser holds a long-lived ID-token cookie issued by your identity provider. On every request the browser sends the cookie to the edge. The edge validates the JWT once and forwards the request to your backend with identity headers — the backend never sees the token itself.
Why your backend never sees the JWT
Two reasons:
- JWT validation belongs at the edge. Public-key cryptography, JWKS fetching, clock-skew handling, key rotation — all of it is done once per request, in one place, by infrastructure that stays up to date. Your application code does not need to know what a JWKS is.
- Headers are easier to reason about than tokens. Your action code reads
ctx.principal.id. It doesn't decode base64, doesn't check anexpclaim, doesn't juggle key IDs. The edge has already done that work.
Your application's trust boundary is "edge ↔ backend," not "browser ↔ backend." That boundary is secured with an HMAC signature on every request — see The HMAC trust boundary below.
Edge-stamped identity headers
When a request arrives at your backend from the edge, the following headers are set. Your action code does not typically read these directly — the runtime maps them to ctx.principal — but knowing what they are helps when debugging.
Identity claims (from the JWT)
| Header | Source | Example | Meaning |
|---|---|---|---|
X-Sub | JWT sub claim | auth0|abc123 or email|user@host.com | Principal identifier. Globally unique within the identity provider. |
X-Email | JWT email claim | user@host.com | User's email address. Present when the provider issues it. |
X-Email-Verified | JWT email_verified claim | true / false | Whether the provider has verified ownership of the email. |
Tenant and routing context (set by the edge)
| Header | Meaning |
|---|---|
X-Tenant-Id | The tenant the request is scoped to (legacy field; see note below). |
X-Site-Id | The site receiving the request. |
X-Project-Id | The project the site belongs to. |
X-Distribution-Id | The specific distribution (immutable deploy) serving this request. |
X-Branch | The branch this distribution was deployed from (e.g. main, preview-12). |
X-Env | The environment (dev, staging, production). |
Note on tenancy. Two header conventions coexist today: the legacy
X-Tenant-Id(a single opaque tenant identifier) and the v4 flow that usesX-Site-Id+X-Project-Id+X-Distribution-Id. Manzano-hosted edge deployments set both. Self-hosted backends can consume either.
Request tracking
| Header | Meaning |
|---|---|
X-Forwarded-Host | The hostname the browser originally requested. |
X-Request-Id | Per-request correlation ID; logged by the edge and the backend. |
The HMAC trust boundary
The edge signs every request it forwards. Your backend verifies that signature before trusting any of the identity headers above.
Three headers carry the signature:
| Header | Meaning |
|---|---|
X-Origin-Auth | Base64-encoded HMAC-SHA256 signature over a canonical string of the request. |
X-Origin-Auth-Version | Currently 4. Reserved for future signature-scheme upgrades. |
X-Origin-Auth-Timestamp | Unix-millisecond timestamp of when the edge signed the request. |
The signed canonical string (v4) concatenates, colon-separated: HTTP method, path, host, protocol, site id, project id, site key, distribution id, manifest id, branch, env, sub, email, email_verified, and timestamp. Any header that a caller might try to spoof is in the signature; any change invalidates the signature.
Why it matters: Without this signature, an attacker who reaches your backend directly (bypassing the edge) could set X-Sub: admin in the request and impersonate any user. The HMAC guarantees the identity headers are real.
Timestamp window. Signatures are valid for a 5-minute window relative to the backend's clock. Requests older than that are rejected regardless of signature. This bounds replay attacks.
Constant-time comparison. Backends must compare signatures in constant time — the runtime does this for you automatically; self-hosted verifiers should use a constant-time byte-compare.
The HMAC secret is a per-distribution shared key, rotated alongside the distribution lifecycle. You do not configure it directly.
How headers become ctx.principal
The Grove runtime reads the identity headers and populates a Principal value on ctx:
ctx.principal field | Source header |
|---|---|
ctx.principal.id | X-Sub |
ctx.principal.claims.email | X-Email |
ctx.principal.claims.email_verified | X-Email-Verified |
ctx.principal.claims.<any> | Additional claims the edge was configured to forward |
Roles are resolved separately through Grove's role-vocabulary mechanism (declared in your Grove code, not injected from the JWT).
Anonymous requests
If no identity header is present — typically because the request is to a public route, or because the user is not signed in — ctx.principal is null.
You can check this in an authorize hook:
authorize read_public_feed(ctx) {
// No check needed; anonymous is fine on this action.
}
authorize read_private_feed(ctx) {
if ctx.principal is null {
fail "Authentication required"
}
}
If you use Cedar for authorization, anonymous requests match the Role::"__anonymous" sentinel. Authenticated requests match Role::"__authenticated". See Authorization Hooks for Cedar integration.
Reading identity in an action
Once authentication is set up, reading identity in your Grove code is straightforward:
action post_comment(ctx, body: String) {
if ctx.principal is null {
fail "Sign in to comment"
}
let comment = Comment {
id: Id.generate("cmt"),
author_id: ctx.principal.id,
author_email: ctx.principal.claims.email,
body: body,
created_at: ctx.now,
}
emit CommentPosted { comment: comment }
}
Note the .id comes from X-Sub, and .claims.email comes from X-Email. No token parsing, no claims validation — the edge did that work.
Session lifetime
| Cookie / credential | Typical lifetime | Controlled by |
|---|---|---|
| ID-token cookie (browser) | 24h (default, provider-configurable) | Identity provider token lifetime |
| Auth state cookie (during login) | Short-lived (minutes) | mzno-auth |
| Built-in email magic link | 10 minutes (default) | Built-in email provider config |
| Edge ↔ backend HMAC window | 5 minutes | Fixed; protects against replay |
When the ID-token cookie expires, the edge treats the user as anonymous and redirects to the login flow on the next protected-route request.
Debugging authenticated requests
Common symptoms and where to look:
| Symptom | Likely cause |
|---|---|
ctx.principal is null on a protected route | ID-token cookie missing or expired — user is anonymous. |
| HMAC verification fails | Backend clock skew > 5 minutes, or the request bypassed the edge. |
X-Sub is present but ctx.principal.claims.email is empty | Identity provider is not issuing the email claim, or the edge's claim-to-header mapping was customized. |
| Request lands on a 401 page before reaching the backend | Edge JWT validation failed (signature, audience, issuer, or expiry). Inspect the edge logs, not the backend. |
For full provider-configuration debugging, see Identity Providers.
See Also
- Authentication Overview — the three-layer mental model.
- Identity Providers — per-provider configuration.
- Enabling Authentication — turning auth on for a project.
- Authorization Hooks — the
authorizehook andctx.principalAPI, including Cedar integration and the__authenticated/__anonymoussentinels. - Subdomains & Edge Routing — how the edge resolves
X-Tenant-Id/X-Site-Id/X-Project-Id.