Security

Public endpoint URLs are designed to be safe to share, but every public ingestion path attracts spam and abuse over time. yorkyy gives you four layers of defense; combine them based on your endpoint's exposure.

Defense in depth

Pick protections based on threat model rather than maxing out every option. Quick recommendations:

ScenarioOrigin allowlistTurnstileHMAC signingHoneypot
Public HTML form (marketing site)✓ (always)
SPA / React component on a public site
Server-to-server (your backend → yorkyy)
Mobile app✓ (via your backend)
Internal / private testing✓ (localhost)

Origin allowlist

By default any origin can POST to an endpoint. Adding origins to the allowlist tells yorkyy to reject POSTs with an Origin or Referer header that doesn't match. Requests with no Origin header (server-to-server) always pass — origins are a browser concept, set by the browser, not by the JavaScript making the request.

Add origins exactly as the browser sends them: https://your-site.com, https://www.your-site.com (these are different — add both if needed), no trailing slash, no path.

Turnstile (Cloudflare CAPTCHA)

Turnstile is a privacy-respecting CAPTCHA from Cloudflare. When enabled, every submission must include a valid Turnstile token in the cf-turnstile-response field. Tokens are verified server-side against Cloudflare's API before the submission is accepted.

To use Turnstile:

  1. Get a sitekey at dash.cloudflare.com → Turnstile
  2. Add the Turnstile widget to your form (Cloudflare's docs cover this)
  3. Enable Require Turnstile on the yorkyy endpoint

Token is appended to the submission as cf-turnstile-response automatically by Cloudflare's widget — you don't need to do anything special.

HMAC request signing

When you can't trust the client (typically anything running in a browser you don't fully control), use HMAC signing. yorkyy generates a 32-byte secret, you sign every request with it, and yorkyy rejects unsigned or wrongly-signed requests with 401 invalid_signature.

The signature format matches what we use for outgoing webhook deliveries — symmetric on both sides.

Header format

X-Yorkyy-Signature: t=<unix-seconds>,v1=<hex-hmac-sha256>

The signature is computed over the literal string `${t}.${rawBody}` — that's the timestamp, a dot, then the unmodified request body bytes. Including the timestamp in the signed payload prevents replay attacks.

Signing in Node / TypeScript

import { createHmac } from "node:crypto";

function sign(secret: string, body: string): { sig: string; ts: number } {
  const t = Math.floor(Date.now() / 1000);
  const v1 = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
  return { sig: `t=${t},v1=${v1}`, ts: t };
}

async function send(endpointUrl: string, secret: string, data: unknown) {
  const body = JSON.stringify(data);
  const { sig } = sign(secret, body);
  return fetch(endpointUrl, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Yorkyy-Signature": sig,
    },
    body,
  });
}

Signing in Python

import hmac, hashlib, time, json, requests

def sign_and_send(endpoint_url: str, secret: str, data: dict):
    body = json.dumps(data)
    t = int(time.time())
    v1 = hmac.new(secret.encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
    return requests.post(endpoint_url, data=body, headers={
        "Content-Type": "application/json",
        "X-Yorkyy-Signature": f"t={t},v1={v1}",
    })

Signing in Go

import (
    "crypto/hmac"; "crypto/sha256"; "encoding/hex"
    "fmt"; "net/http"; "strings"; "time"
)

func sign(secret, body string) string {
    t := time.Now().Unix()
    mac := hmac.New(sha256.New, []byte(secret))
    fmt.Fprintf(mac, "%d.%s", t, body)
    return fmt.Sprintf("t=%d,v1=%s", t, hex.EncodeToString(mac.Sum(nil)))
}

func send(url, secret, body string) (*http.Response, error) {
    req, _ := http.NewRequest("POST", url, strings.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("X-Yorkyy-Signature", sign(secret, body))
    return http.DefaultClient.Do(req)
}

Rotation

Rotating a secret invalidates the old one immediately. Stagger deployments accordingly: ship the new secret to your servers first, then rotate, then confirm traffic continues to flow.

Honeypot fields

Honeypots are hidden form fields that humans don't see and bots auto-fill. Every yorkyy endpoint has one configured (default name _gotcha). Submissions with a value in this field are silently dropped — they return 200 OK so the bot doesn't realize it was caught.

To add a honeypot to a form:

<input
  type="text"
  name="_gotcha"
  style="position:absolute;left:-9999px"
  tabindex="-1"
  autocomplete="off"
/>

Don't use display:none — some bots specifically skip those.

Rate limits

Every endpoint has two automatic limits:

  • Per IP — default 60 requests/minute. Catches single-source spam.
  • Total — default 600 requests/minute. Catches distributed abuse.

Adjust both per endpoint. For low-volume forms (a personal contact form), consider 10/min and 60/min. For an event-registration endpoint expecting spikes, raise the total to several thousand.

Payload limits

LimitValueBehavior on exceed
Total body size32 KB413 Payload Too Large
Field count50Excess fields silently dropped
Field value length10 KBTruncated with … (truncated)
Field name length200 charsTruncated

Data handling

  • Submission values are stored as-is. We don't strip HTML or normalize values — output is escaped at render time (in email HTML, dashboard UI, etc.)
  • IP addresses and User-Agents are recorded for audit and abuse investigation only
  • Signing secrets are encrypted at rest with AES-256-GCM. We can never recover them — only verify against them
  • API keys are stored as SHA-256 hashes only; full plaintext is never persisted
  • Webhook signing secrets are encrypted at rest with AES-256-GCM

Reporting a security issue

If you discover a vulnerability, please email security@yorkyy.comrather than opening a public issue. We'll acknowledge within 48 hours and patch critical issues within 7 days.