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
Two ways to open the management page:
- Sidebar route: directly visit
/system/form-shares. Some style variants list this under the System group; others don't — the route itself always works. - System Hub: visit
/systemand click the Public form sharing tile (added in v3.31.41 — before that the page existed but wasn't linked from the Hub grid).
Once you're in, 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.
File fields are skipped on the default public form
The default /forms/[token] page renders text, email, phone, and textarea inputs only. It does not render :file: / :files: / :image: / :images: fields. If you share a resource that has, say, a category image:file:image column, the public form silently omits that field — the submission still lands but the image column ends up empty.
The mechanical reason: file uploads happen via POST /api/uploads, which lives in the protected Gin group with middleware.Auth in front. An anonymous visitor hitting it gets a 401 before the multipart body is parsed. Removing the auth would expose your bucket to public abuse — anyone could upload anything they liked, then walk away.
Three production-shaped ways to do public file uploads:
- Token-scoped presigned URLs. Add a new public endpoint
POST /api/public/forms/:token/presignthat checks the FormShare bcrypt password, validates the file MIME + size, and returns a one-shot presigned PUT URL to your bucket. The visitor uploads directly to S3 with that URL; the form submit then carries the returned object key. Spend the engineering time once and every public share benefits. - External-link field. Cheapest: change the field type to a plain URL string on the public-facing version of the resource and ask submitters to paste a Google Drive / Dropbox / Imgur link. Works without any backend work, but you depend on a third-party host (links rot, content moderation isn't yours).
- Hand-roll a non-public form behind a magic link. Skip
grit expose form --public-shareentirely. Generate a one-time auth token, email or SMS it to the recipient, and have them sign in via that token to submit through the normal auth'd form. Heavier but gives you the full file upload story without weakening your bucket policy.
Public file uploads via token-scoped presigned URLs are on the roadmap; until they land, pick one of the workarounds above based on how trusted your submitters are.
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.
Audit trail (v3.31.25+)
Every successful public submission writes one row to a form_submissions table — a separate audit log, not a column on the parent record. The row records which share, which resource, which record ID, plus IP and User-Agent for forensics.
type FormSubmission struct {ID string `gorm:"primarykey;size:36" json:"id"`ShareID string `gorm:"size:36;not null;index" json:"share_id"`ResourceName string `gorm:"size:64;not null;index" json:"resource_name"`RecordID string `gorm:"size:36;not null;index" json:"record_id"`IP string `gorm:"size:45" json:"ip"`UserAgent string `gorm:"size:255" json:"user_agent"`CreatedAt time.Time `json:"created_at"`DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`}
Writing the audit row is best-effort — if it fails, the user's submission is not rolled back. They get their record either way; the admin loses one line in the trail. Browse the rows from /system/form-shares → click a share → see every submission with the originating IP and timestamp.
What it can't do (yet)
- Tailored public forms — the default page at
/forms/[token]is a generic name/email/phone/message shape. For resources with different fields, usegrit expose form Contact --public-share(covered in the previous lesson) to scaffold a page with the resource's actual fields. - 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
Form sharing gives anonymous visitors a controlled lane. The final lesson in this chapter covers the opposite case: protecting the web pages that should require a signed-in user — using grit add web-auth to scaffold the middleware and the <ProtectedWebRoute> wrapper component.
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