[docs] Document Postgres World auth/security limitations explicitly - #3908
Conversation
The Postgres World was described as "production-ready" in the docs, the worlds manifest, and the building-a-world guide, while its README mentioned "reference implementation" only in passing and neither said anything about authentication. It inherits its queue HTTP handler from world-local, which validates the x-vqs-* header shape, the queue-name prefix, and the payload schema — never the caller — so any client that can reach POST /.well-known/workflow/v1/flow can forge or replay workflow and step invocations. Make the reference-implementation framing and the bring-your-own-auth expectation explicit instead: - Add a Security section to the package README, HOW_IT_WORKS, and both the v4 and v5 Postgres World docs pages: what is unauthenticated, how to gate it at the network edge, why framework middleware is the wrong layer, and that the World never presents a credential of its own. - Note that the World does not implement getEncryptionKeyForRun(), so data is stored unencrypted, and that self-hosted @workflow/web has no auth. - Drop "production-ready" from the docs pages, the worlds manifest, and the building-a-world reference callout, and add "no built-in authentication" and "no encryption" to the Limitations list. - Stop the Local World pages from pointing at the Postgres World for production without mentioning that it shares the same unauthenticated handler, and warn about self-hosted World security in the Deploying guide and the v5 Worlds configuration reference. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
🦋 Changeset detectedLatest commit: 165c029 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
|
||
| ### Using a third-party World | ||
| <Callout type="warn"> |
There was a problem hiding this comment.
Not needed in this section. Let's leave it in the specific postgres world pages and leave this top level page to just be a short link section
There was a problem hiding this comment.
Reverted — this page is back to just the link section.
|
|
||
| ## Postgres World | ||
|
|
||
| The Postgres World is a self-hosted durable backend for long-running server processes. | ||
| The Postgres World is a self-hosted durable backend for long-running server processes. It is a reference implementation: none of the options below configure authentication, because the World does not authenticate Workflow's internal HTTP routes at all. Restrict them yourself — see [Postgres World security](/worlds/postgres#security). |
There was a problem hiding this comment.
Not needed in this section. This is just general configuration. leave it as is
There was a problem hiding this comment.
Reverted — left as is.
| @@ -68,7 +68,18 @@ For self-hosting or deploying to other cloud providers, you can use community-ma | |||
| </Card> | |||
| </Cards> | |||
|
|
|||
| ### Using a third-party World | |||
| <Callout type="warn"> | |||
There was a problem hiding this comment.
like in v4, it' not needed in this section. Let's leave it in the specific postgres world pages and leave this top level page to just be a short link section
There was a problem hiding this comment.
Reverted here too.
| </Callout> | ||
|
|
||
| <Callout type="info"> | ||
| **Reference implementation:** The [Postgres World source code](https://github.com/vercel/workflow/tree/main/packages/world-postgres) is a production-ready example of how to implement the World interface with a database backend and graphile-worker for queuing. | ||
| **Reference Implementation:** The [Postgres World source code](https://github.com/vercel/workflow/tree/main/packages/world-postgres) is a complete example of how to implement the World interface with a database backend and graphile-worker for queuing. Note that it deliberately leaves authentication to the deployment — see [Postgres World security](/worlds/postgres#security) for what a self-hosted World has to gate itself. |
There was a problem hiding this comment.
| **Reference Implementation:** The [Postgres World source code](https://github.com/vercel/workflow/tree/main/packages/world-postgres) is a complete example of how to implement the World interface with a database backend and graphile-worker for queuing. Note that it deliberately leaves authentication to the deployment — see [Postgres World security](/worlds/postgres#security) for what a self-hosted World has to gate itself. | |
| **Reference Implementation:** The [Postgres World source code](https://github.com/vercel/workflow/tree/main/packages/world-postgres) is a complete example of how to implement the World interface with a database backend and graphile-worker for queuing. It is not optimized for scale, speed, or security and should be used as a reference to full in those gaps in a custom World. Please see the [Postgres World docs](/worlds/postgres) for what a self-hosted World has to gate itself. |
There was a problem hiding this comment.
Applied to both v4 and v5. One tweak: the suggestion had "reference to full in those gaps", which I took as "fill in those gaps".
| @@ -81,4 +81,4 @@ The local world is designed for development, not production: | |||
| - **Single instance** - Cannot handle distributed deployments | |||
| - **No authentication** - Suitable only for local development | |||
|
|
|||
| For production deployments, use the [Vercel World](/worlds/vercel) or [Postgres World](/worlds/postgres). | |||
| For production deployments, use the [Vercel World](/worlds/vercel), which authenticates workflow traffic automatically, or the [Postgres World](/worlds/postgres) — which inherits this queue handler and its lack of authentication, so you must restrict its workflow routes yourself. See [Postgres World security](/worlds/postgres#security). | |||
There was a problem hiding this comment.
| For production deployments, use the [Vercel World](/worlds/vercel), which authenticates workflow traffic automatically, or the [Postgres World](/worlds/postgres) — which inherits this queue handler and its lack of authentication, so you must restrict its workflow routes yourself. See [Postgres World security](/worlds/postgres#security). | |
| For production deployments, use the [Vercel World](/worlds/vercel), which handles execution, persistence, multi-tenancy, scale, observability, and security for you on Vercel, or check out the [Postgres World](/worlds/postgres) - a tested reference implementation that implements the complete World spec and can be used to deploy workflows anywhere. |
There was a problem hiding this comment.
Applied verbatim to both v4 and v5.
| @@ -1,6 +1,6 @@ | |||
| --- | |||
| title: Postgres World | |||
| description: Production-ready, self-hosted world using PostgreSQL for storage and graphile-worker for job processing. | |||
| description: Self-hosted reference world using PostgreSQL for storage and graphile-worker for job processing. Bring your own authentication. | |||
There was a problem hiding this comment.
| description: Self-hosted reference world using PostgreSQL for storage and graphile-worker for job processing. Bring your own authentication. | |
| description: Self-hosted reference world using PostgreSQL for storage and graphile-worker for job processing. |
There was a problem hiding this comment.
Done — also dropped the same trailing sentence from the worlds-manifest.json description.
|
|
||
| This architecture ensures workflows survive application restarts with all state reliably persisted. For implementation details, see the [source code](https://github.com/vercel/workflow/tree/main/packages/world-postgres). | ||
|
|
||
| ## Security | ||
|
|
||
| Unlike the [Vercel World](/worlds/vercel), which authenticates workflow traffic with OIDC tokens automatically, the Postgres World does **not** authenticate the requests that drive workflow execution. Adding that is your responsibility, and it needs to be in place before an app using this World is reachable by untrusted clients. |
There was a problem hiding this comment.
| Unlike the [Vercel World](/worlds/vercel), which authenticates workflow traffic with OIDC tokens automatically, the Postgres World does **not** authenticate the requests that drive workflow execution. Adding that is your responsibility, and it needs to be in place before an app using this World is reachable by untrusted clients. | |
| Unlike the [Vercel World](/worlds/vercel), which authenticates and [encrypts](/docs/how-it-works/encryption) workflow traffic automatically, the Postgres World does **not** automatically authenticate requests that drive workflow execution. Adding that is your responsibility and should be in place before an app using this World is used in production and reachable by untrusted clients. We also recommend [enabling encryption](https://workflow-sdk.dev/docs/how-it-works/encryption#custom-world-implementations). |
There was a problem hiding this comment.
Applied to both. One change: I used the relative /docs/how-it-works/encryption#custom-world-implementations instead of the absolute workflow-sdk.dev URL, so the v5 copy links to the v5 page rather than v4. Anchor verified against ## Custom World Implementations in both versions.
| That route runs workflow orchestration and queued step invocations (on adapters that mount a separate step route, `POST /.well-known/workflow/v1/step` behaves the same way). The handler validates only: | ||
|
|
||
| - The presence and shape of the `x-vqs-queue-name`, `x-vqs-message-id`, and `x-vqs-message-attempt` headers | ||
| - That the queue name carries the expected `__wkf_workflow_` prefix | ||
| - That the body matches the queue payload schema | ||
|
|
||
| There is no request signature, no shared secret, and no caller-identity check. Any client that can reach the route can submit a well-formed message of its own, or re-send a body it captured earlier, and the runtime will execute it — starting or advancing runs and invoking steps directly, including steps your application would normally only reach after its own gating (an approval, a payment check, a permission test). Because nothing in a message is bound to a caller, a replayed delivery is indistinguishable from a genuine one. | ||
|
|
||
| Two related routes have their own limits worth knowing: | ||
|
|
||
| - `POST /.well-known/workflow/v1/webhook/:token`, created by [`createWebhook()`](/docs/api-reference/workflow/create-webhook), is authorized by the token in the URL and nothing else. When you need more than that, use [`createHook()`](/docs/api-reference/workflow/create-hook) behind your own authenticated route and call [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) once you have authorized the request. | ||
| - `GET /.well-known/workflow/v1/manifest.json` responds with `404` unless the `WORKFLOW_PUBLIC_MANIFEST` environment variable is set to `1`. Leave it unset outside of testing — the manifest lists your workflow and step names. |
There was a problem hiding this comment.
Too verbose. It's enough to say that this route is publicly accessible on the internet by default, and should be protected. And then just mention the 2 other routes (webhook and manifest and the exceptions there)
There was a problem hiding this comment.
Cut. The section is now "Protect the queue route": one paragraph saying the flow route is publicly reachable by default and has no signature/secret/caller check, then the two exceptions (webhook token, manifest 404). The header-validation bullet list and the replay paragraph are gone. Applied the same trim to the README.
…lout Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `configure-proxy-handler` id lives on a JSX heading inside a collapsed accordion, so next-validate-link cannot see it and the anchor would not reveal the content anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Blocking all of `/.well-known/workflow/` breaks `createWebhook()`, whose `webhook/:token` route is a sibling under the same prefix and is meant to be reachable by the caller. Also note that WORKFLOW_PUBLIC_MANIFEST is read at build time, and attribute payload validation to the runtime rather than the queue handler, which only checks the header shape and queue-name prefix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VaguelySerious
left a comment
There was a problem hiding this comment.
AI review: no blocking issues
Conflicts with main are resolved and pushed. The overlap was the docs style pass (#3948, #3704): I kept this PR's security content and adopted main's colon separators and bulleted deployment checklist, so the merge is additive to both. Three follow-up commits fix things I found while checking the claims: a </Callout> I dropped in the v5 building-a-world resolution, the Docs Links failure on /docs/getting-started/next#configure-proxy-handler (that id is on a JSX heading inside a collapsed accordion, so next-validate-link cannot see it and the anchor would not reveal the content anyway), and the three items in the inline comments.
I verified the central claim rather than taking it from the description. Driving the queue handler that @workflow/world-postgres actually exposes (world.createQueueHandler, which is world-local's, packages/world-postgres/src/queue.ts:133) with forged requests against a live Postgres:
- a request carrying no credential of any kind is accepted (200) and a forged step invocation (
stepName: chargeCustomerCard) is dispatched to the runtime handler - the handler is told
attempt,queueName,messageIdand nothing about the caller - an identical replay of the same captured message, same
x-vqs-message-id, is accepted and dispatched a second time - a deliberately wrong
authorization/x-vercel-signatureheader changes nothing - controls behave as the page implies: wrong queue prefix and missing
x-vqs-*headers both 400
So the page is accurate about the trust model, and the body is plain JSON (TypedJsonTransport), which makes forging easier than the wording suggests. The loopback default is as described (packages/world-postgres/src/queue.ts:209). Test code was local only, nothing committed.
|
|
||
| Workflow does not prescribe an auth mechanism for self-hosted Worlds, so restrict the flow route at the network edge rather than inside the application: | ||
|
|
||
| - **Keep the flow route unreachable from outside.** By default the worker delivers to a loopback address (`http://localhost:{PORT}`, or `WORKFLOW_LOCAL_BASE_URL` when set), so in the common single-process topology nothing outside the container needs to reach it. Blocking external requests to `/.well-known/workflow/v1/flow` at your ingress, reverse proxy, or firewall costs you nothing, because loopback delivery never traverses that layer. Do not block the whole `/.well-known/workflow/` prefix if you use [`createWebhook()`](/docs/api-reference/workflow/create-webhook): its `webhook/:token` route sits under the same prefix and has to stay reachable by whoever calls it. |
There was a problem hiding this comment.
AI Review: Note
The ingress guidance originally said to block all of /.well-known/workflow/, which would have broken createWebhook(): its webhook/:token route is a sibling under the same prefix and has to stay reachable by whoever calls it (packages/builders/src/vercel-build-output-api.ts:118, packages/nitro/src/index.ts:326). The same section two paragraphs up tells readers that route is authorized by its token, so the page was telling them to block a route it had just described as intentionally public. I pushed the scoping to /.well-known/workflow/v1/flow plus the explicit exception, and the same change in the README. Flagging it here so the reasoning is on the record rather than buried in a commit.
|
|
||
| ## Security | ||
|
|
||
| Unlike the [Vercel World](/worlds/vercel), which authenticates and [encrypts](/docs/how-it-works/encryption) workflow traffic automatically, the Postgres World does **not** automatically authenticate requests that drive workflow execution. Adding that is your responsibility and should be in place before an app using this World is used in production and reachable by untrusted clients. We also recommend [enabling encryption](/docs/how-it-works/encryption#custom-world-implementations). |
There was a problem hiding this comment.
AI Review: Note
I could not verify the first half of this sentence, and I would rather it be checked than published on my read of it.
getEncryptionKeyForRun is implemented for the Vercel World (packages/world-vercel/src/encryption.ts:182), so the encryption half holds. For authentication I found no inbound verification anywhere in the SDK: createQueueHandler in packages/world-vercel/src/queue.ts:511 hands the request to @vercel/queue's handleCallback, and that package's callback parsing (parseRawCallback/parseCallback) only reads CloudEvent headers and the body. Its OIDC use is outbound, for authenticating the SDK's own calls to the queue API, not for checking callers. So whatever protects the deployed flow route is platform-side, not something this adapter does.
I tried to settle it against a preview deployment of this branch and got a confounded answer: POST /.well-known/workflow/v1/flow on workbench-hono-workflow-git-document-pg-world-auth.labs.vercel.dev returns 401, but the body is {"protection":{"vercel_auth_enabled":true}}, so that is deployment protection answering, not workflow-level auth. GET / gives the same redirect, so it tells us nothing about the route.
You know the backend better than I do, so either confirm and leave it, or attribute it to the platform rather than the World. Worth getting exact: it is a security guarantee in a public security section, and it is the sentence a reader will use to decide the Vercel World needs nothing from them.
|
|
||
| - `webhook/:token`, created by [`createWebhook()`](/docs/api-reference/workflow/create-webhook), is authorized by the token in the URL and nothing else. Use [`createHook()`](/docs/api-reference/workflow/create-hook) behind your own authenticated route and [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) when you need more than that. | ||
| - `manifest.json` responds with `404` unless the app was built with `WORKFLOW_PUBLIC_MANIFEST=1`. That variable is read at build time, so unsetting it in the runtime environment of an already-built deployment does not withdraw the manifest. Leave it unset outside of testing, because the manifest lists your workflow and step names. | ||
|
|
There was a problem hiding this comment.
AI Review: Nit
WORKFLOW_PUBLIC_MANIFEST is read at build time, not per request: shouldExposePublicManifest (packages/builders/src/base-builder.ts:2217) is consulted while emitting output, Next writes the file into public/ during the build (packages/next/src/builder-eager.ts:139), and Nitro only registers the route when the variable is set at config time (packages/nitro/src/index.ts:329). The security-relevant direction is the one a reader would get wrong: unsetting it in the runtime environment of an already-built deployment does not withdraw the manifest. I pushed that clause here and in the README.
|
|
||
| The worker sends workflow orchestration and queued step messages to the combined `.well-known/workflow/v1/flow` endpoint. | ||
|
|
||
| That endpoint is not authenticated: the queue HTTP handler is inherited from `@workflow/world-local` and checks the `x-vqs-*` header shape and the queue-name prefix, but never the caller, so restricting the route is left to the deployment. The payload is validated later, by the runtime that consumes the message. See [Security](./README.md#security). |
There was a problem hiding this comment.
AI Review: Nit
The queue handler does not check the payload schema. packages/world-local/src/queue.ts:406 validates the x-vqs-* header shape and the queue-name prefix, then JSON-deserializes the body and calls the handler; the schema check happens downstream in the runtime (WorkflowInvokePayloadSchema.parse, packages/core/src/runtime.ts:745). I confirmed it by driving the real handler with {"total":"garbage","not":"an invoke payload"}: status 200 and the payload was dispatched to the consumer untouched. Since this line exists to describe the trust boundary precisely, I moved payload validation to the runtime in both this file and the README.
|
|
||
| The Postgres World does not currently implement `getEncryptionKeyForRun()`, so it does not participate in Workflow's [end-to-end encryption](/docs/how-it-works/encryption): workflow and step inputs and return values, hook payloads and metadata, and stream chunks are all stored in your database in readable form. A World derived from this one can opt in by implementing that one method; see [Custom World implementations](/docs/how-it-works/encryption#custom-world-implementations). Until then, protect the database, its credentials, and its backups accordingly. | ||
|
|
||
| The [observability UI](/docs/observability) has no authentication of its own either. If you self-host `@workflow/web` against your Postgres database, put it behind your own auth layer, because everyone who can reach it can read every run. |
There was a problem hiding this comment.
AI Review: Nit
Good addition, and it matches the notice @workflow/web's README already carries. One gap: the paragraph covers self-hosting @workflow/web, but npx workflow web and the CLI's inspect commands read the same database with no auth either, and the Observability section above tells readers to use exactly those against WORKFLOW_POSTGRES_URL. Not worth another paragraph; if you touch this again, widening it to "the observability UI and CLI read every run" would close it.
VaguelySerious
left a comment
There was a problem hiding this comment.
AI review: no blocking issues
Approving. Conflicts with main are resolved, all 180 checks pass, and the docs claims hold up against the code.
Unit Tests (windows-latest) went red once on packages/world-local/src/streamer.test.ts > scopes chunk listing to the stream, not the whole world, which ran 70729ms and timed out after one retry. That was the only input to the E2E Required Check failure; every E2E lane was green. This branch has a zero-file delta from the main commit it merges (git diff 74058c141c HEAD is the 11 docs, README, manifest and changeset files and nothing else), so no code in that test's path changed here. It passed on re-run.
Unrelated to this PR, worth knowing: the in-flight-before-decision-counted sim scenario fails on main as it stands. I ran node run.ts --report-only in workbench/sim-world at origin/main on macOS and got 40 passed, 1 failed, with check failed: the count guard fenced the write the watermark let through. It never gates CI because the test script passes --report-only and exits 0 regardless, so it has been failing quietly. Given the scenario exists to assert the count half of the fence fires, that is worth a look on its own.
|
Backport PR opened against |
Why
Someone pointed out that the generated internal queue endpoints accept forged, unauthenticated requests when using the Postgres World. That's true, and it's the intended trust model for a reference implementation — but our docs didn't say so anywhere:
docs/content/worlds/{v4,v5}/postgres.mdx,worlds-manifest.json, and the building-a-world guide all called it "production-ready".Per the thread: we can't prescribe how people do auth, so the outcome is to be explicit about the limitation, the way
@workflow/webalready is about bringing your own auth.Docs only — no behavior change.
What is actually unauthenticated
packages/world-postgres/src/queue.tstakescreateQueueHandlerstraight from@workflow/world-local, and that handler (packages/world-local/src/queue.ts:373) validates only thex-vqs-*header shape, the__wkf_workflow_queue-name prefix, and the payload schema. No signature, no shared secret, no caller identity. So anything that can reachPOST /.well-known/workflow/v1/flowcan start/advance runs and invoke steps directly, including a replay of a previously captured body.Changes
Package docs
packages/world-postgres/README.md— reference-implementation notice at the top (including the recommendation to clone and adapt for production), plus a## Securitysection: protect the queue route, bring your own auth, and data at rest.packages/world-postgres/HOW_IT_WORKS.md— one-line note on the trust assumption where HTTP delivery is described.Docs site (v4 + v5)
worlds/*/postgres.mdx— dropped "production-ready" from the frontmatter and intro, added awarncallout, a## Securitysection, a deployment-checklist item, and "reference implementation" / "no built-in authentication" / "no encryption" to Limitations.worlds/*/local.mdx— reworded the "for production deployments" pointer.worlds/*/building-a-world.mdx— the reference-implementation callout no longer claims production-ready.worlds-manifest.json— description no longer claims production-ready.The auth guidance is deliberately network-level (ingress / reverse proxy / firewall, mTLS or a shared-secret header when the routes cross hosts) rather than framework middleware, because:
/.well-known/workflow/*from the proxy matcher, and a handler that consumes the internal body breaks execution; andAlso documented, since they came up while tracing the trust boundary: the
webhook/:tokenroute is authorized by the token alone,manifest.jsonis 404 unlessWORKFLOW_PUBLIC_MANIFEST=1, and the World does not currently implementgetEncryptionKeyForRun()— with a pointer to Custom World implementations, since a derived World can opt into encryption with that one method.Review round 2
All 16 comments from @pranaygp are addressed and replied to inline:
docs/*/deploying.mdx(v4 + v5) anddocs/v5/configuration/worlds.mdx— those pages stay a short link section and general configuration.getEncryptionKeyForRun().Two small deviations, both flagged in the threads: the building-a-world suggestion's "reference to full in those gaps" is applied as "fill in those gaps", and the encryption link uses the relative
/docs/...form so the v5 copy links to v5 rather than to the v4 page.Docs Preview
Base URL from the
vercel[bot]comment (workflow-docsrow): https://workflow-docs-git-document-pg-world-auth.vercel.sh — links require Vercel team access.🤖 Generated with Claude Code