Authentication
The framework treats the userId you supply as authoritative. Verifying
that the userId belongs to the caller is your application's job —
typically your auth middleware runs before the framework's HTTP handler
and rejects anything it can't identify.
Every inbound request to a flow has to be tied to a principal: the caller identity the runtime keys state, resources, and request records by. The framework gives you a hook to attach a principal to a request. It does not store secrets, run OAuth, or look up users in your database. That stays on your side.
This is the same trust model Mastra, LangGraph, and other application-layer frameworks use. It works because the trust boundary is your auth code, not the framework's HTTP handler.
Who's responsible for what
Your job. Verify the credentials your transport carries — session
cookie, JWT, signed webhook header — and produce a userId and an orgId
that the framework can trust. If the credential is missing or
invalid, reject the request before it reaches the framework, or throw
PrincipalResolutionError from inside the resolver.
The framework's job. Enforce that the identity attached to a request is internally consistent with the rest of the runtime. Two checks:
- Session binding. Once a session is created with
userId: A, later requests claiminguserId: Bagainst that session are refused withUserBindingMismatchError, and nothing from them is written. Same fororgIdviaOrgBindingMismatchError. See Session consistency check. - Schema compatibility. If two flows on the same server declare
incompatible
user.stateSchema(ororg.stateSchema) shapes,FlowRegistry.registerthrowsCrossFlowSchemaConflictErrorat startup. See Cross-flow schema compatibility. - Identity source. The
userIdandorgIdan action runs under come from the resolver. Posting anorgIdin the request body does not change which org the action runs in. If you want a caller to be able to name its own org, read it inside the resolver, where you can check it against the credential you just verified. The resolver is given the parsed body asmetadata.bodyfor exactly this.
The framework does not — and cannot — verify that userId: A actually
belongs to whoever sent the request. That's the credential your middleware
or resolver verified before the framework ever saw the call.
Every request runs in an organization
There is no such thing as a request without one. A session, a request, a dispatched child, a scheduled job: each carries an organization, and the server checks it on reads and on execution alongside the user.
It comes from one of two places, and never from the caller:
- A configured resolver returns the organization it verified. If it
returns none, a blank one, one that isn't well-formed Unicode, or the
reserved default below, the request is
refused with 401. Returning
nullis refused too:defaultUserIdfills in a user, never an organization. - No configured resolver at all, which is the development case. The
framework supplies
DEFAULT_ORG_ID, exported from@flow-state-dev/core, and warns once per host that it has done so.
import { DEFAULT_ORG_ID } from "@flow-state-dev/core";
DEFAULT_ORG_ID is a reserved id for running a single organization without
authentication. It is not a security boundary, and a configured resolver
cannot claim it.
A machine caller with no end user returns the organization on its own and
lets defaultUserId name the system user:
authentication: {
resolvePrincipal: async (ctx) => ({ orgId: await verifyServiceToken(ctx) }),
defaultUserId: "system"
}
Calling the runtime directly, below any transport, means there is no resolver to supply an identity, so you pass one:
await runAction({
flow,
actionName: "chat",
input,
userId,
orgId: DEFAULT_ORG_ID, // or the organization you verified
stores
});
An organization id can be any string except an empty one, a whitespace-only
one, or one that isn't well-formed Unicode (a lone UTF-16 surrogate). Those are
rejected, not trimmed or repaired. Every other string is stored exactly as
given. isValidOrgId, exported from @flow-state-dev/core, applies the same
check, so a resolver can use it and refuse with its own message.
A session's organization is fixed when the session is created, like its user. Reopening cannot move it; create a new session instead.
A stored session or request can carry no organization at all. The server
preserves such a record and refuses to serve it until an operator attributes
it offline. An authenticated caller who addresses one gets
409 migration-required. A caller your resolver could not identify gets the
ordinary 401, exactly as they would for a record that does carry an
organization. Listings omit the record either way. See
Which organization a record belongs to.
What requireUser: true does (and doesn't)
requireUser: true is the default. It means the request must produce
some userId after the resolver and defaultUserId fallback have run.
If neither yields a userId, the framework rejects with 401.
It is a presence check, not an authentication check. A misconfigured
resolver that returns { userId: "anyone" } for every caller still
satisfies requireUser: true. The framework only asks "is there a
userId?" — not "does this userId belong to the caller?"
To enforce real identity, do it inside resolvePrincipal: throw
PrincipalResolutionError for invalid credentials, return null to fall
through to defaultUserId, or look the resolved userId up against your
own user store before returning it.
requireUser: false opts a flow out of user-scope identity entirely. The
framework then refuses to compile the flow if it declares any user-scope
state, resources, or client block. Use it for webhooks and scheduled jobs
that legitimately have no end user.
The hook
defineFlow accepts an authentication config:
import { defineFlow } from "@flow-state-dev/core";
defineFlow({
kind: "billing",
authentication: {
resolvePrincipal: async (ctx) => {
// ctx.source: 'http' | 'mcp' | 'webhook' | 'scheduled' | 'cli' | ...
// ctx.request — present for HTTP-shaped transports
// ctx.envelope — flowKind, action, sessionId, metadata, input
// ctx.rawBody — preserved by HTTP for signature verification
const session = await readSession(ctx.request);
if (session === null) return null;
return { userId: session.userId, orgId: session.orgId };
}
},
actions: { /* ... */ }
});
The resolver returns a ResolvedPrincipal, a partial { userId?, orgId? },
or null. Whichever shape it returns, it must yield an organization — see
Every request runs in an organization.
Throwing a PrincipalResolutionError lets you pick the response
status (401 for invalid signature, 403 for valid signature on a forbidden
resource, etc.).
Resolution order
For each request:
- The transport adapter builds a
PrincipalResolutionContext. - The host picks the resolver: per-flow
authentication.resolvePrincipalif set, otherwise the host-level fallback. - Result has no
userIdanddefaultUserIdis set → usedefaultUserId. - Still no
userIdandrequireUser !== false→ 401. - Principal stamped onto the envelope; runtime continues.
Adapters never implement auth themselves. They call host.resolvePrincipal
and the host applies the per-flow routing transparently.
Three patterns
Browser sessions over HTTP
import { getSession } from "@/lib/auth";
defineFlow({
kind: "chat",
authentication: {
resolvePrincipal: async ({ request }) => {
const session = await getSession(request);
if (session === null) return null;
return { userId: session.user.id, orgId: session.user.orgId };
}
},
actions: { /* ... */ }
});
When the resolver returns null and requireUser: true (default), the
framework rejects with 401. The browser client sees a clean
Unauthorized response and your existing redirect-to-login flow handles
the rest.
Stripe webhook with HMAC signature
Webhooks have no end user. You verify the signature, then return a
host-defined system principal — or skip the resolver entirely and set
defaultUserId.
import {
createHmacVerifier,
PrincipalResolutionError
} from "@flow-state-dev/engine";
const verifyStripe = createHmacVerifier({
secret: process.env.STRIPE_WEBHOOK_SECRET!,
format: "stripe",
toleranceSeconds: 300
});
defineFlow({
kind: "stripe-webhook",
authentication: {
requireUser: false,
defaultUserId: "system",
resolvePrincipal: ({ request, rawBody }) => {
const sig = request?.headers.get("stripe-signature") ?? null;
if (rawBody === undefined || !verifyStripe(rawBody, sig)) {
throw new PrincipalResolutionError("Invalid signature", { status: 401 });
}
// The organization is required; the user is not. Returning `{ orgId }`
// alone lets `defaultUserId` name the system user.
return { orgId: process.env.STRIPE_WEBHOOK_ORG_ID! };
}
},
actions: { /* ... */ }
});
requireUser: false opts the flow out of user-scope identity at build
time — the framework rejects any user-scope state, client block, or
resource declarations on this flow. The runtime still needs a userId
for RequestRecord.userId and friends; that's what defaultUserId
covers.
createHmacVerifier({ format: "stripe" }) matches Stripe's canonical
t=<timestamp>,v1=<signature> format and rejects timestamps older than
toleranceSeconds. For GitHub-style sha256=<hex> headers, use
format: "raw" with prefix: "sha256=".
If you're mounting the webhook transport, you usually don't
write this resolver at all. The webhook adapter does signature verification
itself, per provider, at the mount — stripeWebhookVerifier,
githubWebhookVerifier, slackWebhookVerifier, and createWebhookVerifier
wrap the same HMAC machinery shown here. Reach for createHmacVerifier inside
resolvePrincipal only when you're handling a signed POST through the plain
HTTP transport rather than the webhook transport.
MCP / API token over Authorization header
import { isValidOrgId } from "@flow-state-dev/core";
import {
createHs256JwtVerifier,
extractBearerToken,
PrincipalResolutionError
} from "@flow-state-dev/engine";
const verifyJwt = createHs256JwtVerifier({
secret: process.env.JWT_SECRET!,
issuer: "https://my-app.example.com",
audience: "api.my-app.example.com",
clockSkewSeconds: 30
});
defineFlow({
kind: "private-flow",
authentication: {
resolvePrincipal: ({ request }) => {
const token = extractBearerToken(request?.headers.get("authorization"));
const payload = verifyJwt(token);
if (payload === null) {
throw new PrincipalResolutionError("Invalid token", { status: 401 });
}
const org = payload.org;
if (!isValidOrgId(org)) {
throw new PrincipalResolutionError("Token carries no organization", {
status: 401
});
}
return { userId: payload.sub as string, orgId: org };
}
},
actions: { /* ... */ }
});
Asymmetric algorithms (RS256, ES256) need JWKS resolution and aren't
covered by the built-in helper. Plug in your own verifier inside
resolvePrincipal — the contract doesn't care which library you use.
Sharing resolvers across flows
Per-flow hooks rather than registry-level hooks because flows often have different requirements (one public, one private; one MCP-exposed, one not). When flows do share auth logic, pull the resolver into a constant:
import type { ResolvePrincipalFn } from "@flow-state-dev/engine";
const sessionResolver: ResolvePrincipalFn = async (ctx) => {
const session = await getSession(ctx.request);
return session === null ? null : { userId: session.user.id };
};
defineFlow({ kind: "flow-a", authentication: { resolvePrincipal: sessionResolver }, /* ... */ });
defineFlow({ kind: "flow-b", authentication: { resolvePrincipal: sessionResolver }, /* ... */ });
Host-level fallback
createFlowState accepts a resolvePrincipal option used when an
inbound flow has no authentication.resolvePrincipal of its own:
import { createFlowState, inMemoryStores } from "@flow-state-dev/engine";
export const flowstate = createFlowState({
flows: { chat: chatFlow },
stores: { default: { primary: inMemoryStores() } },
resolvePrincipal: async (ctx) => readSession(ctx.request)
});
Per-flow defineFlow({ authentication }) always wins over this fallback.
fsdev run and fsdev chat ask these resolvers too, with source: "cli" and no request.
Only those two commands produce that source. A request from any transport that claims it,
including a custom adapter, is refused with 401 before your resolver runs, so a resolver can
branch on it. See Who a run is.
What a resolver protects
Configuring resolvePrincipal covers the whole flow API, not just action
calls. The same hook runs for session reads and writes, session state,
resource content, request control (stream, abort, resume, status), and the
debug endpoints.
The framework resolves a principal through your hook on each of those
requests, then checks that the principal owns what the URL addressed. A
session or request belongs to the userId it was created under. A caller
holding a valid credential for a different user gets a 404 for someone
else's session, the same answer as for an id nobody has used. Someone else's
request gets that same 404, whether the call reads its status or stream or
would retry, continue, resume or abort it. A request stream resumed from a
cursor (starting_after or Last-Event-ID) is the one read that answers
differently for an unused id, with an empty 200, and someone else's request
gets that too. Creating a session is the one write that shows an id is taken:
it answers 409 whoever holds the id. The record's organization is checked as well, so one
person who belongs to two organizations cannot reach the first one's session
while acting for the second:
GET /api/flows/sessions/abc123 no credential -> 401
GET /api/flows/sessions/abc123 bob's, session belongs to alice -> 404
GET /api/flows/sessions/abc123 alice's, session belongs to acme,
alice is acting for globex -> 403
GET /api/flows/sessions/abc123 alice's, acting for acme -> 200
Listing endpoints scope to the caller instead of rejecting. GET /api/flows/sessions returns the caller's sessions. The userId query
parameter still works as a filter, but it can only narrow that set, never
widen it. On a flow with its own resolver, a session is listed when that
resolver identifies the caller as its owner, the same test that lets them
open it. If you also set a host-level resolver and it rejects the caller, the
listing answers 401, whatever the flow's own resolver would say.
POST /api/flows/:flowId/sessions takes the new session's userId and
orgId from the principal. An orgId in the request body is never consulted,
on this route or on an action call. A userId in the body is ignored when a
resolver is configured.
Without a resolver
If you haven't configured resolvePrincipal, on the flow or as a host-level
fallback, the framework reads body.userId and the management endpoints stay
open. That's the development default, and it's why fsdev serve refuses to
bind a non-loopback host until your flows have authentication configured. See
the CLI reference.
Flows are checked independently. On a server where one flow configures a resolver and another doesn't, the second flow's sessions stay open.
The two endpoints that span every flow, GET /api/flows/sessions and GET /api/flows/active-requests, need a host-level resolver to identify the caller
for the flows that have no resolver of their own. Without one, both endpoints
still answer, and rows from those flows go to any caller. Rows from a flow with
its own resolver go only to the caller that resolver identifies as the row's
owner, the same caller who could open that session or request. A caller the
flow's resolver rejects gets no error, just none of that flow's rows.
Addressed routes, and what they scope by
Every read outside those two flow-spanning endpoints names a session or a
request in the path, and the framework loads that record and checks its owner
before your handler runs. GET /api/flows/sessions/:sessionId/children — the sessions started
under a conversation — is one of these. Its answer is
scoped to the addressed conversation's owner, tenant and org, all read from the
stored record, so a child inherits the parent conversation's access rules
rather than getting its own. It is not scoped to a flow: work the conversation
handed to another flow is listed too.
The flow that governs such a read is the one recorded as the record's owner,
not one the caller names. A session created through review-east is checked by
review-east's resolver on every later read and re-entry, even when the URL
names nothing but the session. On the action routes, which do name a flow, the
address and the stored owner have to agree before the resolver is consulted:
| Address | Stored owner | Result |
|---|---|---|
review-east | review-east | review-east's resolver runs, then the ordinary principal and tenant checks |
review-east | review-west | 409 wrong-instance-session, before any resolver |
review-east | none recorded, kind has one instance | treated as that instance's |
review-east | none recorded, kind has several instances | 409 migration-required (see Persistence) |
The owner id is an address, not an authorization. Knowing that a session is
review-east's gives a caller nothing the resolver would not grant anyway.
A session's request history keeps to its owning flow. Its request list, its state with items, and its stream show only requests that flow ran, so passing a session's check never gets a caller another flow's requests or items. When the session runs again, the conversation history sent to the model is built from those same requests. A model in one flow never sees turns from another flow. The sessions it started are listed as the children route lists them, in its stream too, including work handed to another flow.
No endpoint on the flow API enumerates across owners. A caller reaches background work through the conversation that started it; there is no mode that returns every session on the server.
Ids you choose are addresses
A client can pick its own ids. The one you'll reach for most is requestId on
an action call: send the same one again, say after a dropped connection, and
you reach the same request instead of starting a second one.
await fetch("/api/flows/support/s_123/actions/reply", {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ requestId: "req_7f3a", input: { text: "Where's my order?" } })
});
An id tells the framework where to look. It never says who owns what's there.
If another user already has a request under req_7f3a, your call still
succeeds, as your own request, under an id the response gives you in the
x-request-id header and the 202 body. Their request is untouched, and sending
req_7f3a again lands on that same request of yours. With a resolver
configured, the request routes check the caller against the user and
organization stored on the request, not against knowing the id. Anything you
ask of their request, its status, its stream, a retry, a continue, a resume or
an abort,
gets you the same answer an id nobody has used gets, so it doesn't even say the
id is taken. That's a 404, except for a stream you resume from a cursor,
which answers an empty 200 either way.
Session ids are addresses too, and they turn up in URLs all the time. With a
resolver configured, if another user already has a session under the id you
send, you get the same 404 Unknown session you'd get for an id nobody has
used, on every session route and on an action posted into it. Nothing of their
session reaches you, not even whose it is, and nothing you send is written into
it. The one place the id shows it's taken is creating a session with it, which
answers 409 whoever holds it. Pick a fresh id and retry.
GET /api/flows/sessions/s_42 bob's, s_42 belongs to alice -> 404
POST /api/flows/support/s_42/actions/reply bob's, s_42 belongs to alice -> 404
POST /api/flows/support/sessions bob's, body sessionId "s_42" -> 409
So ids don't have to be unguessable to keep users apart. They show up in response headers, URLs and logs, and nothing depends on them staying secret. If you generate them on the client, anything unique per call will do.
What the resolver sees
On these routes the resolver gets the Request (headers, cookies, URL) and no
parsed body. Reading a credential from a header or a cookie works the same as
it does on an action call. A resolver that reads metadata.body gets
undefined here: a GET has no body to read, and the framework won't decide
access from a field the caller writes.
Session consistency check
A session's userId and orgId are immutable for the session's lifetime.
Once a session has been created against a particular identity, every
later request that loads it has to match. If it doesn't, the framework
throws:
UserBindingMismatchError— request supplied auserIdthat doesn't match the session's owner. Thrown before the request is accepted, so nothing is written and no request exists to poll. Over HTTP the caller gets404 Unknown session, and the error's message never names the owner.OrgBindingMismatchError— the request resolved to an organization that doesn't match the session's bound org.
This is a structural integrity check, not an identity check. Your auth
code is what guarantees a request actually represents userId: A. The
framework guarantees that once a session belongs to userId: A, no
request can route data through it under a different identity — whether
the mismatch came from a buggy client, a routing mistake, or a stale
cookie that drifted between users.
A request the check refuses leaves the session as it was. It never shows in the session's snapshot or live stream, it doesn't become the session record's latestRequestId, and when the session is a dispatch run, the run's updatedAt doesn't move.
To "move" a session to a different user or org, create a new session.
Cross-flow schema compatibility
User and org records are shared across every flow registered on the same
server by default, a user's within one organization. A chat flow that writes
user.preferences.theme and an admin flow that reads it touch the same
UserRecord. That's usually what you want — preferences, profile fields, org
settings belong to the user or the org, not to one flow.
For shared-by-default to be safe, the framework checks at startup that
every flow's user.stateSchema and org.stateSchema are structurally
compatible with the others already in the registry. Incompatible
declarations throw CrossFlowSchemaConflictError immediately, naming
the offending field and the two flows involved. You see it once, in
development, and fix it before users do.
Identical schemas merge cleanly. Schemas that one flow extends with extra optional fields merge with a warning. Anything that would actually overwrite data — same field, different type — throws.
When sharing isn't appropriate, set isolateUserState: true (or
isolateOrgState: true) on defineFlow. The flow's user (or org) state
is then stored separately and excluded from the schema check — separately
per flow copy, so a definition running as several named copies keeps one
private record each. See
Sharing State Across Flows for the full
opt-out story.
That separation is about storage, not about permission. It decides where a flow's data is filed, and stops another flow reading or overwriting it. It does not decide whether the caller in front of you is allowed to act as this user — that is what the rest of this page is for, and isolation neither adds to it nor stands in for it.
For triggering a flow from code outside the HTTP layer — where you supply the
resolved userId yourself — see Calling a flow without a transport.
What the framework does not do
- Store credentials, OAuth tokens, or webhook secrets.
- Implement RS256/ES256 JWT verification (needs JWKS, separate concern).
- Run an OAuth provider.
- Verify that a
userIdactually belongs to the caller. That's your middleware orresolvePrincipalhook.
For the contract details and edge cases, see
docs/architecture/authentication.md.