---
title: Challenge-Gated Creation
description: Gate anonymous (or any) record creation behind a proof-of-work / CAPTCHA challenge via creationAccess.requireChallenge — the generic, config-driven replacement for bespoke spam-protected form endpoints
date: 2026-07-02
tags:
  - datatypes
  - access
  - permissions
  - anonymous
  - altcha
  - developer
---

Any datatype write access operation can require a **challenge** (proof-of-work / CAPTCHA) before the operation runs. This is how public, anonymous forms (contact, sign-ups, registrations) are protected from bots — **by configuration**, not a bespoke endpoint per form.

## `requireChallenge` on an access operation

`AccessOperationMembers` (used by `creationAccess` and the RLS `create` / `update` / `delete` operations) accepts an optional `requireChallenge`:

```jsonc
{
  "creationAccess": {
    "permission": "anonymous",     // who may create
    "requireChallenge": "altcha"   // …but only with a solved challenge
  }
}
```

- The only challenge type today is **`altcha`** (self-hosted, GDPR-friendly proof-of-work). The set is extensible via a server-side registry, so new types can be added without touching the generic create/update/delete code.
- `read` **ignores** `requireChallenge` (reads also flow through sync; gating them is nonsensical).
- The field is optional — existing datatypes omit it and behave exactly as before. **No migration is needed** to adopt it.

Set it in the backoffice **datatype access editor**: a `requireChallenge` select appears on `creationAccess` and on RLS read/update/delete.

## The header contract

The solved challenge travels as a request header — **never** as a field on the entity:

```
x-<challengeType>-challenge: <solution>
```

e.g. `x-altcha-challenge: <base64 solution>`. On a gated write the server verifies this header **after** the access check; a missing or failing solution → **403**, and nothing is mutated.

## Endpoints

| Method & path | Auth | Purpose |
| --- | --- | --- |
| `GET /api/projects/:projectId/challenges/:challengeType` | public | Issue a fresh, solvable challenge (`404` for an unknown type). |
| `POST /api/projects/:projectId/types/:typeId/data` | public | Generic create. Access is decided by the type's `creationAccess`; the `requireChallenge` precondition is enforced here. Anonymous forms POST the raw entity JSON with the `x-<type>-challenge` header. |

## Building a public form

Use `DatatypeCreateFormComponent` (client): give it a `typeId` + `projectId` and it renders the datatype's generated Input form, fetches + renders the challenge widget **directly above the submit button** when the operation requires one, blocks submit until solved, and POSTs to the create route with the header. No per-form code.

Downstream processing (notify sales, create a CRM lead) is a **workflow** on the datatype (`onCreate` document trigger → actions), not endpoint code.

## `ALTCHA_HMAC_KEY` (required in production)

ALTCHA challenges are stateless — signed with an HMAC key at issue and re-verified from the signature, so every pod must share the **same** key.

- **Non-production**: falls back to a fixed insecure dev key, so local dev + E2E work with no configuration.
- **Production**: set `ALTCHA_HMAC_KEY` to a strong random secret (e.g. `openssl rand -hex 32`). If it is unset in production, issuance throws and verification **fails closed** (every gated submission is rejected).
