Public form sharing — token-gated submissions
Generate a shareable link for any resource. Optional bcrypt password. No new endpoints.
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 formid, token, password_hash, │enabled, label, │ POST /api/public/forms/<token>/submitsubmission_count │ { _password, fields }} ▼▲ dispatch service│ │ verifies token + password└ submission_count++ ◀──────────┴ calls Contact's Createreturns { 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(notcontactsorcontact-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.
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:
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:dispatchdefault:return nil, fmt.Errorf("public submission disabled for %q (no dispatch case registered)", resourceName)}}
- Resource whitelisting — only the resources with an explicit
casecan be created via a share. Thedefaultbranch 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's
BeforeCreateruns (UUID generation), validations hold, and any GORM associations are preserved. Public submissions are just regular creates with an anonymous caller.
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 submittinghas_password— whether to show a password gatelabel— 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_idcolumn 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
Try it
In your contact-app, exercise the full sharing flow:
- Open the admin →
/system/form-shares. Create a share forContactwith the label "Test campaign" and no password. Copy the URL. - Open the URL in an incognito tab. You should see the form with no admin chrome.
- Submit with name = "Alice", email = "alice@test.com".
- Back in the admin, refresh the shares table — the submission count is now 1. Visit
/resources/contacts— Alice is in the list. - 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