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.principal in your authorize hook 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:

  1. 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.
  2. Headers are easier to reason about than tokens. Your action code reads ctx.principal.id. It doesn't decode base64, doesn't check an exp claim, 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)

HeaderSourceExampleMeaning
X-SubJWT sub claimauth0|abc123 or email|user@host.comPrincipal identifier. Globally unique within the identity provider.
X-EmailJWT email claimuser@host.comUser's email address. Present when the provider issues it.
X-Email-VerifiedJWT email_verified claimtrue / falseWhether the provider has verified ownership of the email.

Tenant and routing context (set by the edge)

HeaderMeaning
X-Tenant-IdThe tenant the request is scoped to (legacy field; see note below).
X-Site-IdThe site receiving the request.
X-Project-IdThe project the site belongs to.
X-Distribution-IdThe specific distribution (immutable deploy) serving this request.
X-BranchThe branch this distribution was deployed from (e.g. main, preview-12).
X-EnvThe 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 uses X-Site-Id + X-Project-Id + X-Distribution-Id. Manzano-hosted edge deployments set both. Self-hosted backends can consume either.

Request tracking

HeaderMeaning
X-Forwarded-HostThe hostname the browser originally requested.
X-Request-IdPer-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:

HeaderMeaning
X-Origin-AuthBase64-encoded HMAC-SHA256 signature over a canonical string of the request.
X-Origin-Auth-VersionCurrently 4. Reserved for future signature-scheme upgrades.
X-Origin-Auth-TimestampUnix-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 fieldSource header
ctx.principal.idX-Sub
ctx.principal.claims.emailX-Email
ctx.principal.claims.email_verifiedX-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 / credentialTypical lifetimeControlled 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 link10 minutes (default)Built-in email provider config
Edge ↔ backend HMAC window5 minutesFixed; 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:

SymptomLikely cause
ctx.principal is null on a protected routeID-token cookie missing or expired — user is anonymous.
HMAC verification failsBackend clock skew > 5 minutes, or the request bypassed the edge.
X-Sub is present but ctx.principal.claims.email is emptyIdentity 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 backendEdge 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