Hardening a Supabase Edge Function Intake Form with Validation, Preview, Honeypot, and Duplicate Checks
This Post walks through the hardening phase of the DataInsideData™ Builder Showcase intake pipeline project.
This is where the system moved from a foundation of:
Nice, this form works!
to:
The form works, protects the database, helps users catch mistakes, and blocks obvious duplicate/spam behavior.
That was a meaningly shift.
The pipeline looks like this:
Jekyll / static form
↓
preview-before-submit
↓
Supabase Edge Function
↓
validation + duplicate checks
↓
Postgres table insert
↓
review queue
↓
promotion / publishing workflow
This guide is written for folks learning full-stack systems, static sites, Supabase, and backend validation. It is also practical enough for production-minded builders who want to avoid the classic mistake of maybe trusting the browser a bit too much.
Why frontend validation is not enough
Frontend validation is helpful because it gives users fast feedback.
But frontend validation is not the source of truth.
Anything in the browser can be bypassed.
That means this is good:
HTML required fields
JavaScript preview checks
friendly error messages
But this is necessary:
Supabase Edge Function validation
backend duplicate checks
backend honeypot check
backend API key check
The rule I use:
Frontend validation improves the experience.
Backend validation protects the system.
Add preview-before-submit
The preview step improves quality before the data reaches Supabase.
The workflow becomes:
fill form
↓
preview cleaned values
↓
make edits if needed
↓
confirm and submit
This catches:
misspelled title
wrong repo URL
missing tags
missing required fields, etc...
When the user clicks Make Edits, send focus back to the form:
if (editButton) {
editButton.addEventListener("click", function () {
previewPanel.hidden = true;
pendingPayload = null;
status.textContent = "Make your edits, then preview again.";
const firstField = document.getElementById("first_name");
form.scrollIntoView({
behavior: "smooth",
block: "start"
});
window.setTimeout(function () {
if (firstField) {
firstField.focus({ preventScroll: true });
}
}, 400);
});
}
That small UX detail matters. It keeps users from getting stranded at the bottom of the page.
- The preview page needs to disappear after form submission, which will be addressed in the other round of modifications.
Require the right fields
The form should require:
first name
last name
email
repo URL, etc...
Optional fields should be labeled:
email (Optional)
---
## 4. Use strong acknowledgement language
The ownership checkbox should be specific:
```text
I confirm that I own this project, contributed to it, or have permission to submit it for review.
The public showcase checkbox should also be intentional:
I understand that, if approved, this project will be displayed publicly on the DataInsideData™ Builder Showcase...
- These boxes should not be pre-checked.
- The user should actively acknowledge them.
Add a honeypot field
A honeypot is a field real users should never fill.
Bots may fill it because they see it in the HTML.
Frontend Div Block:
<div class="did-honeypot" aria-hidden="true">
<label for="website">Website</label>
<input
type="text"
id="website"
name="website"
autocomplete="off"
tabindex="-1"
>
</div>
I used this SCSS styling, keeping hidden for seamless UX:
.did-honeypot {
position: absolute;
left: -10000px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
Backend filtering:
if (cleanString(payload.website)) {
errors.push("Invalid submission.");
}
That provides a simple first-layer bot filter.
Validation using the Edge Function
The Edge Function should validate a number of key targets:
required fields
email shape
category allowlist
tag count
checkbox acknowledgements
honeypot, etc...
Example repo URL validation:
function isValidGitHubRepoUrl(value: string): boolean {
try {
const url = new URL(value);
const isGithubHost =
url.hostname === "github.com" || url.hostname === "www.github.com";
const parts = url.pathname
.split("/")
.map((part) => part.trim())
.filter(Boolean);
return isGithubHost && parts.length >= 2;
} catch {
return false;
}
}
Store request metadata carefully
For basic operational visibility, the Edge Function can store:
user_agent
request_origin
request_referer
client_ip_hash
Example added columns:
Interaction with Supabase table is achieved using psycopg2, pgadmin, and postgresql
alter table public.builder_project_submissions
add column if not exists user_agent text,
add column if not exists request_origin text,
add column if not exists client_ip_hash text;
I prefer storing an IP hash instead of raw IP Address.
That means the system can recognize repeated behavior later without casually storing the plain network address.
I Need a Salt, but What is it?
A salt is a secret random value mixed into another value before hashing.
Instead of storing:
123.45.67.89
hash it instead:
SECRET_SALT:123.45.67.89
That makes the stored hash harder to guess or reverse by precomputing common values.
Generate a salt using command:
python -c "import secrets; print(secrets.token_hex(32))"
And set it as a Supabase secret:
npx supabase secrets set DID_IP_HASH_SALT="PASTE_RANDOM_HEX_STRING"
In the Edge Function:
const salt = Deno.env.get("DID_IP_HASH_SALT");
Privacy note: this is still security/anti-abuse metadata. It will be treated intentionally and documented in the privacy policy when implemented.
It’s important to avoid heavy-handed blocking
So do not block:
same email alone
same repo URL alone
same IP hash alone
Why?
Because productive builders may make multiple submissions.
And monorepos are real—I know I have one.
So the better rule is:
same actual submission evidence
And not the:
same person
Add duplicate checks
Duplicate rules:
same email + same repo URL + same project title within 10 minutes
→ block
IP hash
→ store for future analysis
Use 409 Conflict for duplicate submissions.
That is the balance: stop duplicates, but encourage productive users.
Add browser cooldown
This is simply a UX helper, not the main security layer.
After successful submission:
localStorage.setItem(
"did_builder_last_submission_at",
String(Date.now())
);
Before confirming submission:
const lastSubmissionAt = Number(
localStorage.getItem("did_builder_last_submission_at") || "0"
);
const cooldownMs = 60 * 1000;
if (lastSubmissionAt && Date.now() - lastSubmissionAt < cooldownMs) {
status.textContent =
"Please wait a moment before making another submission.";
return;
}
This prevents rapid accidental resubmits from the same browser.
Deploying the Edge Function
First, run a quick check in the supabase project function folder:
cd supabase/functions/submit-builder-project
deno check index.ts
Deploy:
npx supabase functions deploy submit-builder-project
If the CLI asks for login:
npx supabase login
IF the browser doesn’t execute / appear automatically
- Copy the address from terminal and paste into browser.
- Then sign into supabase account
- Return to terminal
- Select the desired project from the terminal list
Then deploy again.
Test is Key
Test these:
valid form
unchecked ownership
unchecked public showcase
blank category
blank tags
bad repo URL
honeypot filled
same email + repo + title duplicate
same email + same repo + different title, etc...
The goal is not to block productive users.
The goal is to block bad payloads, obvious bots, and duplicate project submissions.
Final takeaway
This phase is where the system starts to feel real.
The form is not just collecting data anymore.
Its guiding the user, protecting the backend, preserving trust, and keeping the workflow useful for all.
That is the difference between:
a form
and:
an intake system
Data Inside Data™.
Tech Hands, a Science Mind, and a Heart for Community.