← All articles
App Development Business Leaders · · By ObjectStack Team

How Do You Let Anonymous Visitors Submit a Form Into a Governed Business App? One Public Door, Five Fields Wide

Since v17 anonymous data access is always denied. The one open door is a public form view: the runtime derives a grant from the form itself — five fields in, one row created, only its id back. Measured on 18.3.0.

How Do You Let Anonymous Visitors Submit a Form Into a Governed Business App? One Public Door, Five Fields Wide How Do You Let Anonymous Visitors Submit a Form Into a Governed Business App? One Public Door, Five Fields Wide
  • Public forms
  • Web-to-lead
  • Anonymous access

The short version. Since v17, an unauthenticated request for object data in an ObjectStack app is refused with 401 UNAUTHENTICATED — every object, every endpoint, no opt-out. The one door a governed app leaves open to the public is a public form view: declare a form, mark its sharing block allowAnonymous: true, and the runtime derives the visitor’s entire authorization from that declaration — create one record on that one object, nothing else, and the answer is the new record’s id. The form’s field list is the security boundary. The CRM example’s web-to-lead form lists five fields, so five fields can arrive; the other eight business fields and every system column cannot, whatever the visitor sends, and a field the form marks required must be filled before anything is written. I will call this a form-shaped grant: authorization computed from the form at request time, which is why it cannot be configured wider than the form. Everything below was measured on the published @objectstack/cli 18.3.0, the version npm create objectstack@latest installs today — including whether a guest_portal permission set is needed. It is not, and since 18.0 the shipped example says so too.

The problem, in the doc’s own words

The ObjectStack guide on public data collection opens with the scenario as a user would state it:

I want anonymous visitors to submit a form — a “Contact us” / “Request a demo” / intake form — that creates a record. But I must not give them access to the base, and they must only be able to set the fields on the form.

Every business app has one of these: a lead form on the website, a support intake, a demo request, a warranty claim. The usual answers each cost something. A “guest” role with write access to the table is a standing grant that nobody re-reads, and it drifts. A hand-written endpoint bypasses the permission model that governs everything else. A third-party form tool with a webhook means two systems now hold the schema and one of them is not the one your reviewers look at.

Since v17 the first answer is not even available. The permissions documentation records why in passing: “since v17 anonymous access to object data is always denied anyway — the requireAuth: false opt-out was retired”. The self-hosting walkthrough turns that into a verification step — curl an object endpoint with no credentials and insist on the 401 with a machine-readable code — and treats a 200 there as an incident. So the honest form of the question is: if the data plane is closed to anonymous callers by construction, how does a public form get through at all?

A public form is a declaration, not a permission

This is the whole of the CRM example’s web-to-lead form, as it ships at the @objectstack/[email protected] tag:

formViews: {
  web_to_lead: {
    type: 'simple',
    data: { provider: 'object', object: 'crm_lead' },
    sections: [
      {
        name: 'contact_us',
        label: 'Contact us',
        columns: 1,
        fields: [
          { field: 'name', required: true },
          { field: 'company' },
          { field: 'email', required: true },
          { field: 'phone' },
          { field: 'title' },
        ],
      },
    ],
    sharing: {
      enabled: true,
      allowAnonymous: true,
      publicLink: '/forms/contact-us',
    },
  },
},

Nothing in it mentions a permission. The sharing block is what opens the door: with enabled, allowAnonymous and a publicLink all set, the runtime registers two anonymous endpoints — GET /api/v1/forms/contact-us, which returns the resolved form and the whitelisted schema, and POST /api/v1/forms/contact-us/submit, which inserts one crm_lead. The doc’s one-line rule for what the visitor may write is “Only the fields listed in sections[].fields are accepted on submit; everything else is stripped server-side.” The next sentence, added in 18.0, says the submit route “also enforces each entry’s required: true before anything is written”. And the reason the doc gives for building it this way is the sentence this post exists to test: “Authorization is derived from the declaration, not configured separately — so the grant can’t drift wider than the form.”

Two nets: the route keeps only the five declared fields, drops server-managed names and refuses an empty required field; the data layer admits insert and read-back on crm_lead only and strips the same names again. The visitor gets the new id; reads answer 401; a withdrawn form answers 404.

Mechanically there are two nets, and they are worth naming because a reviewer can check each one.

The first net is the route. The submit handler builds its allow-list from sections[].fields of the matched form and copies only those keys out of the request body. Before it does, it drops by name every key in PUBLIC_FORM_SERVER_MANAGED_FIELDS, a set the spec package exports: id, owner_id, owning_business_unit_id, queue, organization_id, tenant_id, created_at, created_by, updated_at, updated_by, is_deleted, deleted_at and __search — thirteen names that are “never accepted from a public submission — not even when a section declares them”. (queue is the newest; 18.2 had twelve.) A form with no fields at all is refused with 400 rather than accepting whatever arrives. Then the route judges the form itself, before anything is written: a field the form marks required and always shows must carry a value (since 18.0), a value in a file field is refused (since 18.2), and so is a value outside the options subset a form entry offers (since 18.3) — all in one 400 VALIDATION_FAILED that names each field.

The second net is the data layer. The route attaches a request-scoped publicFormGrant: { object: 'crm_lead' } to the write, and the security plugin honours exactly that: insert, plus find, findOne and count for the immediate read-back, on that object only. Before admitting the insert it strips the same thirteen names a second time, so that even if a form declared owner_id in a section, or the route filter and the data layer were ever edited apart, a visitor could not forge ownership or tenant placement. The grant is the form’s shape, read at request time. There is no row in a permissions table that says “anonymous may write to leads”, because there is nothing to configure. The read-back reaches the route, not the visitor: the route answers with the created id and nothing else the insert stored.

The transcript, on 18.3.0

I scaffolded a fresh app with npm create objectstack@latest — @objectstack/cli 18.3.0 — and copied in, from the @objectstack/[email protected] tag, the CRM example’s crm_lead object (with the two objects its lookups name), its lead.view.ts with the web-to-lead form, and its guest_portal permission set. Then objectstack dev, which compiles the config at boot and seeds an admin account on an empty database. Every response below is real and trimmed only for length.

1. The door describes itself — five fields wide. With no credentials:

curl -s http://localhost:49900/api/v1/forms/contact-us
{ "slug": "contact-us", "object": "crm_lead",
  "form": { "sections": [ { "name": "contact_us", "label": "Contact us", "fields": [
      { "field": "name", "required": true }, { "field": "company" },
      { "field": "email", "required": true }, { "field": "phone" }, { "field": "title" } ] } ],
    "sharing": { "enabled": true, "publicLink": "/forms/contact-us", "allowAnonymous": true } },
  "objectSchema": { "name": "crm_lead", "label": "Lead", "fields": {
      "name":    { "label": "Lead Name", "type": "text",  "required": true, "maxLength": 200 },
      "email":   { "label": "Email",     "type": "email", "required": false },
      "phone":   { "label": "Phone",     "type": "phone", "required": false },
      "company": { "label": "Company",   "type": "text",  "required": false, "maxLength": 200 },
      "title":   { "label": "Title",     "type": "text",  "required": false, "maxLength": 120 } } } }

The objectSchema carries exactly the five whitelisted fields, each with the label, type and limits a front end needs to draw it. crm_lead declares thirteen business fields in its object definition; the public schema shows five and says nothing about status, lead_score, assigned_to, account, notes or the rest. The visitor cannot even learn what to try. The labels follow the visitor’s language: with the CRM’s Chinese translation bundle loaded, the same call sent with Accept-Language: zh-CN served the section heading, the object label and all five field labels in Chinese, under Vary: Accept-Language.

2. A clean submission creates one row, and the answer is its id.

curl -s -X POST http://localhost:49900/api/v1/forms/contact-us/submit \
  -H 'content-type: application/json' \
  -d '{"name":"Ada Lovelace","email":"[email protected]"}'
HTTP 201
{ "id": "dgYZFAvljruviVy9" }

That is the whole answer: the new record’s id, and nothing the insert stored. Read back as the admin, the row shows what the visitor did not send:

curl -s -b admin.cookies http://localhost:49900/api/v1/data/crm_lead/dgYZFAvljruviVy9
{ "id": "dgYZFAvljruviVy9", "name": "Ada Lovelace", "email": "[email protected]",
  "status": "new", "source": "web", "lead_score": 0, "owner_id": null, "queue": null,
  "organization_id": "org_mw8n9qe618ypwdpk", "created_by": null }

status: "new" was not sent; it is the object’s write-path default, which the lead object declares so that “a create that omits status (e.g. the public Web-to-Lead form, which doesn’t collect it) is stored as ‘new’, so it satisfies required”. source: "web" and lead_score: 0 are defaults too. On 17.7.0 the 201 echoed this whole row to the anonymous caller. I reported that, and since 18.0 the answer is the id alone (objectstack issue #22437, fixed by PR #22462).

3. A hostile submission creates the same row. Six keys sent; two are on the form:

curl -s -X POST http://localhost:49900/api/v1/forms/contact-us/submit \
  -H 'content-type: application/json' \
  -d '{"name":"Mallory","email":"[email protected]",
       "status":"converted","lead_score":100,
       "owner_id":"attacker","organization_id":"org_other"}'
HTTP 201
{ "id": "R2Wkp0koxTAw53tN" }

The answer has the same shape as before, so it says nothing about what was kept. The admin read-back of that id does:

{ "id": "R2Wkp0koxTAw53tN", "name": "Mallory", "email": "[email protected]",
  "status": "new", "lead_score": 0, "owner_id": null, "queue": null,
  "organization_id": "org_mw8n9qe618ypwdpk" }

The two whitelisted fields arrived, the two business fields that are not on the form were dropped and the object’s defaults applied, and the two system anchors were dropped and then stamped by the server — organization_id is the deployment’s own organization, not org_other, and owner_id is null. On a second boot I added queue, id and created_by to the same body; the stored row had a server-generated id and null in both of the others. The 201 does not say which keys were refused; the route drops them without comment.

Key sentOn the form?Stored as (admin read-back)Why
name, emailyesas sentwhitelisted
status: "converted"no"new"not in sections[].fields; object default applies
lead_score: 100no0not in sections[].fields; object default applies
owner_id: "attacker"nonullserver-managed name, denied in both nets
organization_id: "org_other"nothe server’s ownserver-managed name; stamped by the runtime
queue, id, created_by (second boot)nonull, a server-generated id, nullserver-managed names

4. A required field left out is refused before the write. The form marks email required; the object does not.

curl -s -X POST http://localhost:49900/api/v1/forms/contact-us/submit \
  -H 'content-type: application/json' \
  -d '{"name":"No Email"}'
HTTP 400
{ "error": "Email is required", "code": "VALIDATION_FAILED",
  "fields": [ { "field": "email", "code": "required", "message": "Email is required", "label": "Email" } ] }

A blank " " email answered the same, and an empty body named both fields in one envelope ("Lead Name is required; Email is required"). On 17.7.0 the same POST answered 201 with email: null, because only the object’s required was enforced on this path. I reported that too, and since 18.0 the route enforces the form’s own required first (objectstack issue #24131, PR #24161).

5. The door does not open the other way. The same anonymous client, now reading:

curl -s -w '\n%{http_code}\n' http://localhost:49900/api/v1/data/crm_lead
curl -s -w '\n%{http_code}\n' http://localhost:49900/api/v1/data/crm_lead/R2Wkp0koxTAw53tN
{"error":"UNAUTHENTICATED","code":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint."}
401

Both answer 401 — the list, and the single row the visitor created a second earlier. The submitter never sees the stored record at all: the 201 carries its id, and the id opens nothing. “The public path only ever inserts; it never lists.”

6. Withdrawing the form closes both endpoints. Keep the publicLink, set enabled: false, restart objectstack dev:

GET  /api/v1/forms/contact-us         → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"}
POST /api/v1/forms/contact-us/submit  → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"}

The lead count read as the admin was the same before and after those two calls: nothing was created. Five status codes tell the whole story of the door: 201, 201, 400, 401, 404.

The guest profile question, settled upstream

On 17.7.0 the documentation and the shipped example disagreed about what authorizes the write. The guide said, as it still does: “Declare a public form view; the submit route derives a narrow authorization from that declaration. No guest profile, no broad grant”. The CRM example shipped a permission set named guest_portal with allowCreate: true on crm_lead, its comment said that profile “authorizes the write”, and the route’s docstring warned that without one the middleware “falls open”. I removed the set from the 17.7.0 CRM, ran the probes again, got identical answers, and reported the disagreement (objectstack issue #24130).

It was fixed in 18.0 (PR #24237). At the 18.3.0 tag the comment above the set reads “It does NOT authorize the public Web-to-Lead form (lead.view.ts web_to_lead). That form’s own declaration does”, and it ends with “the submit answers the created id only”. The form’s own comment says “No permission set is needed, GuestPortalProfile included”. The guide gained a paragraph that opens “The submit’s execution context also names a guest_portal permission set. That name authorizes nothing”, and the “falls open” docstring is gone. The example keeps the set because ADR-0138 records it as the reference for the name the submit context requests.

I re-ran the comparison on 18.3.0 anyway: the scaffold with the set, and again with permissions: [], the compiled artifact listing no permission set at all.

ProbeWith guest_portalWithout guest_portal
POST …/submit with name, email201 { id }, row created201 { id }, row created
POST …/submit smuggling status, lead_score, owner_id, organization_id, queue201 { id }; read back status: "new", lead_score: 0, owner_id: null, queue: null, server’s organization_ididentical
POST …/submit without email400 VALIDATION_FAILED, email requiredidentical
anonymous GET /api/v1/data/crm_lead401 UNAUTHENTICATED401 UNAUTHENTICATED
anonymous GET of the created row401 UNAUTHENTICATED401 UNAUTHENTICATED

Identical, line for line, as on 17.7.0. What the name still reaches is narrow: a set registered as guest_portal supplies the field permissions the grant’s result masker applies to the record handed back to the route, a record the visitor no longer sees. So the practical advice stands: do not add a guest profile to make a public form work. It adds a standing grant to your access matrix that the public door never consults. If your app commits an access-matrix snapshot, as the full CRM does, adding or removing such a set is a change the build makes you review; that gate is its own topic.

One place the example and the guide still part ways: the guide’s step three is “Set sharingModel: 'private' on the object so submissions are staff-scoped after creation”, and the CRM’s lead object is deliberately public_read_write, with a comment explaining that the demo is org-shared on purpose. That does not weaken the door — the organization-wide default governs what signed-in users see of each other’s rows, and the anonymous caller is refused before any of that is consulted — but it is advice worth following on your own intake object.

What this door does not do

  • No rate limiting by default, no captcha, no spam control. On the default scaffold, 40 submissions from one client in 0.89 seconds all answered 201. The runtime does ship one limiter, server-wide and off by default: with server: { security: { rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 10 } } } in the stack, the same client got nine 201s and then 429 RATE_LIMIT_EXCEEDED with a Retry-After header, its earlier GET of the form having drawn on the same budget. It meters every route, keyed by the caller’s address when there is no session, so behind a reverse proxy it counts the proxy’s address unless you declare server.trustProxy, which is safe only when that proxy overwrites the forwarding headers. It is a budget, not bot protection: put the usual edge protection in front of /api/v1/forms/*.
  • The form’s required covers only what the form always shows. An entry with hidden: true, or a visibleWhen on the entry or its section, is not judged on submit, because nothing server-side evaluates a form’s visibleWhen. If such a field must be present, require it on the object or check it in a hook.
  • The answer tells the visitor almost nothing, by design. The 201 carries the new id and no account of what was kept or dropped. A form that needs to show a reference can name an engine-generated field, such as an autonumber case number, in its thank-you panel as {{record.case_number}}; the guide says the answer then carries that value under fields, and os validate and the publish gate refuse any other field. The CRM form names none, so I did not measure that branch. On 17.7.0 the echo also carried whatever a beforeInsert or afterInsert hook stamped, and this post advised keeping record-lookup hooks off public-intake objects; since 18.0 the route filters what the visitor is shown, so that advice is retired.
  • The owner is nobody. owner_id stays null on a public submission; the security plugin leaves it “for object hooks / the first-admin bootstrap to assign”. If your pipeline assigns leads, do it in a hook or a flow — do not expect the row to belong to a person.
  • A withdrawal takes a restart. Since 18.2 objectstack dev compiles the config at boot by default, and a plain restart closed the form here. On 17.7.0 my first attempt changed nothing, because a --no-watch boot served the previous build. An agent-driven loop that only reads status codes should check for the 404, not assume it.
  • Withdrawal across metadata layers has edges this post did not walk. An organization’s saved copy of a view, and forms shipped by packages, follow their own rules in the guide. The single switch tested here is the one most apps need.

A reviewer’s five questions

Whether the form was written by a person or by an agent, the review is the same, and it is short because the grant is the form.

  1. Which forms are public? Every form view with sharing.enabled, sharing.allowAnonymous and a publicLink all set is an anonymous endpoint. The compiled artifact lists them; there is nowhere else to look.
  2. What is in sections[].fields? That list, minus the thirteen server-managed names, is the public write surface. Read it as you would read a firewall rule.
  3. Does the object fill in what the form leaves out? Every required field the form does not collect needs an object-level default — status in the example — or the submission fails. A field the form shows and marks required is enforced at the door; one it hides or shows conditionally needs its required on the object.
  4. Is there a standing anonymous grant anywhere? A permission set bound to a guest or everyone position, or a guest_portal set added “to make the form work”, is not needed and widens nothing the door uses. Treat its presence as a question.
  5. What is in front of the door? Bot protection is the deployment’s job. The runtime’s own limiter is a server-wide budget, and it is off until you declare it.

This is the same place-of-enforcement argument as where agent permissions are enforced, applied to a caller with no identity at all: the check runs in the route and the data layer, not in the UI that happens to hide the fields. And it is the answer to the review problem the Lovable post describes — the accountable owner can read the five-line field list and sign off on it, which is not true of a generated policy. The glossary entry on the permission model has the general shape; this door is the one deliberate exception to “deny by default”, and it is shaped like the form.

Point your agent at it

If an agent writes your app, the thing to hand it is not this article but the rule: a public form is a formViews entry with a sharing block, the field list is the boundary, the form’s required is checked at the door, the object owns defaults, and no permission set is involved. The guide to configuring a coding agent for ObjectStack covers how that rule reaches the agent — the rule file, the MCP endpoint a running app exposes, and the spec. npm create objectstack@latest installs 18.3.0, the version this transcript ran on.

To run it, the ObjectStack repository ships ObjectOS Community — the ghcr.io/objectstack-ai/objectstack image, os start and the Console, free and self-hosted — while ObjectOS Cloud is the hosted edition and ObjectOS Enterprise the self-managed, licensed one.