Public form sharing — token-gated submissions

Generate a shareable link for any resource. Optional bcrypt password. No new endpoints.

11 minmedium

Every Grit resource lives behind admin auth by default — only signed-in operators can submit the form. That's the right default, but plenty of real flows need an anonymous lane: a contact form on a marketing site, a job application form, a lead capture from a campaign QR code. Public form sharing (shipped in v3.31.20) gives you a token-protected URL for any resource without writing a single new endpoint.

The mental model

Generate a share in the admin for a specific resource. You get a long random token + an optional bcrypt password. Anyone with the link (and the password, if set) can submit the form. The submission goes through a dispatch service that decides which resources are reachable publicly — so a stray token can't conjure rows for resources you never exposed.

Operator Anonymous visitor
───────── ─────────────────
/system/form-shares /forms/<token>
│ "New share for Contact" │ GET /api/public/forms/<token>
│ optional password │ → { resource_name, has_password }
▼ ▼
FormShare row { fills out form
id, token, password_hash, │
enabled, label, │ POST /api/public/forms/<token>/submit
submission_count │ { _password, fields }
} ▼
▲ dispatch service
│ │ verifies token + password
└ submission_count++ ◀──────────┴ calls Contact's Create
returns { id, label }

Creating a share

Open the admin → sidebar → System Public form sharing. Click New share and fill in:

  • Resource name — PascalCase, must match a resource the dispatcher knows about. Type Contact (not contacts or contact-app).
  • Label — optional operator-facing tag like "Q3 lead form" or "Application — Engineering". Helps you find this share later.
  • Password — leave blank for an open share, or set one to gate the form. The plaintext password is hashed (bcrypt cost 10) before storage; you can't retrieve it later — only verify or replace it.
Tokens are 32-character URL-safe base64 (24 random bytes, ~191 bits of entropy). Sentinel still rate-limits the public endpoint by IP, so brute-forcing a token is effectively impossible within any reasonable timeframe.

Sharing the link

Each row in the shares table has a Copy button — pastes the public URL to your clipboard. It looks like:

http://localhost:3000/forms/9CkLh7gJZQrPeNwMo3F8x_iVjA8U2nXt

Send that link by email, embed it as a QR code, link to it from a marketing site — Grit doesn't care. The page lives at apps/web/app/forms/[token]/page.tsx and renders a minimal name / email / phone / message form by default.

How dispatch works (the security boundary)

The clever bit is which resources can be submitted publicly. Look at apps/api/internal/services/form_share_dispatch.go:

apps/api/internal/services/form_share_dispatch.go
func SubmitSharedForm(db *gorm.DB, resourceName string, fields map[string]interface{}) (*SharedResourceSubmission, error) {
switch resourceName {
case "Contact":
item := &models.Contact{}
body, _ := json.Marshal(fields)
if err := json.Unmarshal(body, item); err != nil {
return nil, fmt.Errorf("decoding Contact body: %w", err)
}
if err := db.Create(item).Error; err != nil {
return nil, fmt.Errorf("creating Contact: %w", err)
}
return &SharedResourceSubmission{ID: item.ID, Label: item.Name}, nil
// grit:form-share:dispatch
default:
return nil, fmt.Errorf("public submission disabled for %q (no dispatch case registered)", resourceName)
}
}
  • Resource whitelisting — only the resources with an explicit case can be created via a share. The default branch refuses everything else with a clear error.
  • Field whitelisting — JSON is decoded onto the typed model. Unknown keys in the request body are silently ignored; private columns (id, created_at, version, deleted_at) are owned by GORM and untouchable from the wire.
  • Per-resource hooks fire — the model'sBeforeCreate runs (UUID generation), validations hold, and any GORM associations are preserved. Public submissions are just regular creates with an anonymous caller.
Each grit generate resource automatically appends a case to this file at the // grit:form-share:dispatch marker. You don't manage the switch by hand — it's the same marker pattern as routes and AutoMigrate.

What the visitor sees

The public page at /forms/[token] first fetches GET /api/public/forms/:token to learn:

  • resource_name — what kind of thing they're submitting
  • has_password — whether to show a password gate
  • label — the operator-facing tag, used as the page title

Then it renders the form. If the share is password-protected, the password input appears above the form fields and is submitted as a _password alongside the regular fields. The API verifies bcrypt on each submit — there's no session, no cookie, no JWT for public submissions.

Disabling, regenerating, deleting

From the shares table you can:

  • Toggle enabled — flips a boolean. Disabled shares return 404 on the public endpoint. Use this to pause a campaign without losing the share record.
  • Delete — soft-deletes the share. The token stops working forever. Existing submissions are kept.

To regenerate a token (e.g. you accidentally posted it publicly), delete the share and create a new one. There's no in-place rotation — by design. A fresh share gives you a clean submission count too.

What it can't do (yet)

  • Audit trail — there's no source_share_id column on submitted records yet, so the admin Contacts list can't filter to "public only" submissions. On the roadmap.
  • Tailored public forms — the default page is a generic name/email/phone/message shape. For resources with different fields, generate a custom page with grit expose form (covered in the next lesson) and point your share label at it.
  • CAPTCHA / honeypot — Sentinel rate-limits by IP, but there's no challenge mechanism yet. For high-volume public forms behind a marketing site, layer your own (Cloudflare Turnstile, hCaptcha) on top.

Quick check

You created a share for 'Customer' but the public submit endpoint returns `public submission disabled for "Customer"`. What's wrong?

Try it

In your contact-app, exercise the full sharing flow:

  1. Open the admin → /system/form-shares. Create a share for Contact with the label "Test campaign" and no password. Copy the URL.
  2. Open the URL in an incognito tab. You should see the form with no admin chrome.
  3. Submit with name = "Alice", email = "alice@test.com".
  4. Back in the admin, refresh the shares table — the submission count is now 1. Visit /resources/contacts — Alice is in the list.
  5. Edit the share, set a password, save. Submit again from the incognito tab without a password → 401. Add the password to the visible form input → 201.

Paste the URL of the share and the JSON response of the successful submission into notes.md.

What's next

Sharing gives you a link. The next lesson covers grit expose form + grit expose table — commands that scaffold a tailored Next.js page in apps/web/ for any resource, with the exact fields the resource has (not the generic shape). Together with form shares, these are how you surface Grit resources outside the admin panel.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub