Sign-up and abuse controls v0.4¶
With self-service sign-up, each person with an e-mail address can make a public workspace. Public workspaces run untrusted data through trusted code: only built-in models and declarative specs (cut line A). This page is for the person who operates the platform. It tells how sign-up works, what stops abuse, and what operators examine. Each variable on this page is in the configuration reference. The HTTP operations are in the HTTP API reference.
How sign-up works¶
sequenceDiagram
participant B as Browser
participant API as API (/auth)
participant M as Mailer
B->>API: POST /auth/signup {email, name, organisation, accepted_terms}
API->>API: terms, blocklist, bot check, rate limits
API->>M: verification link (single use, 24 h)
API-->>B: 202 {"status": "check_email"}
B->>API: POST /auth/verify-email {token}
API->>API: create tenant (public), project "default", admin grant; Sybil checks
API-->>B: 200 {tenant_id, login_url}
B->>API: GET /auth/login (OpenID sign-in with that address)
POST /auth/signupfirst refuses requests that can never sign up. These are: terms not accepted (TERMS_NOT_ACCEPTED), an address that is malformed or disposable (EMAIL_NOT_ALLOWED), and a failed bot check. Then it applies the rate limits below (RATE_LIMITEDwithRetry-After).- It keeps the sign-up with an HMAC of a new 200-bit token. It never keeps the token. It
sends the link
MAPI_SIGNUP_VERIFY_URLby e-mail. Only the newest link of an address works. - The answer is the same for all addresses, new or already registered. Thus nobody can use sign-up to find out who has an account. If an account that is already there verifies a new sign-up, it gets one more workspace.
-
POST /auth/verify-emailuses the token. In one transaction, it makes these items:- The tenant (tier
public, with an id from the organisation name). - Its
defaultproject. - The person as tenant admin. This is an invitation that the person gets at the first sign-in with that verified address.
- The verification record of the tenant.
A link that is expired, replaced or used answers
410 LINK_EXPIRED. - The tenant (tier
The audit log records all events: auth.signup, auth.signup_rejected, auth.email_verified,
tenant.create and auth.signup_flagged. It records a limited number of
auth.signup_rejected events for each client in each minute, because each person can cause
them.
Mailers¶
MAPI_MAILER |
Use | Notes |
|---|---|---|
smtp |
Production | smtplib with STARTTLS (necessary, never opportunistic) or implicit TLS (MAPI_SMTP_TLS=ssl, port 465). The mailer verifies certificates. Needs MAPI_SMTP_HOST. Credentials in MAPI_SMTP_USERNAME/MAPI_SMTP_PASSWORD. No third-party mail API |
outbox |
dev profile, end-to-end tests |
One JSON file for each message (mode 0600) under MAPI_OUTBOX_DIR. The dev profile serves the newest messages at GET /dev/outbox?to=<address>, with the token in tags.token |
console |
Single-user installations | Logs the message, with the link |
none |
Sign-up is off |
The multi-tenant profile refuses console and outbox while sign-up is on. With these
mailers, each person who can read the logs or the disk could verify all addresses. Without an SMTP host,
sign-up is off (POST /auth/signup answers 403). Messages are plain text. The mailer
refuses header values with line breaks. Thus nobody can inject a header through the name of
a sign-up.
# dev: follow a verification link without a mail server
curl -s -X POST localhost:8000/auth/signup -H 'Content-Type: application/json' \
-d '{"email":"ana@acme.example","name":"Ana","accepted_terms":true,"terms_version":"2026-10"}'
TOKEN=$(curl -s 'localhost:8000/dev/outbox?to=ana@acme.example' | jq -r '.items[0].tags.token')
curl -s -X POST localhost:8000/auth/verify-email -H 'Content-Type: application/json' \
-d "{\"token\":\"$TOKEN\"}"
Disposable addresses¶
The built-in list (mapi_spec.trust.DEFAULT_DISPOSABLE_DOMAINS) is small on purpose. To add
domains, use MAPI_SIGNUP_BLOCKED_DOMAINS (comma-separated). You can also point
MAPI_SIGNUP_BLOCKLIST_PATH at a file (one domain on each line, # comments). For example,
use a public disposable-domain list that your deployment updates. A domain on the list also
blocks its subdomains. If a deployment wants a vendor check, it can add a
mapi_spec.trust.DisposableEmailPolicy to TrustServices.email_policy.
Rate limits¶
| Limit | Default | Variable |
|---|---|---|
| Sign-ups for each client address in each hour | 5 | MAPI_SIGNUP_PER_IP_PER_HOUR |
| Verified sign-ups for each e-mail domain in each day | 25 | MAPI_SIGNUP_PER_DOMAIN_PER_DAY |
| Verified sign-ups on the whole platform in each hour | 500 | MAPI_SIGNUP_GLOBAL_PER_HOUR |
| Verification attempts for each client address in each minute | 20 | MAPI_VERIFY_PER_IP_PER_MINUTE |
The server counts the limits in Postgres from the sign-ups. Thus the limits apply across API
instances. Each request counts toward the limit for each address. This limit stops floods of
unverified sign-ups. The domain and platform caps count only sign-ups whose link was opened.
If not, a person could send 25 false @gmail.com (or @your-customer.com) addresses. That
domain could then not sign up for one day.
Large free-mail domains (MAPI_SIGNUP_SHARED_EMAIL_DOMAINS, by default gmail.com,
outlook.com, yahoo.com, icloud.com, proton.me and similar domains) have no cap for
each domain. Behind the Google Cloud load balancer,
Cloud Armor adds rate bans for each IP in front of
/auth/signup and /auth/*. These bans apply before a request goes to the API. The server keeps client
addresses only as keyed digests.
The client address is the peer that connects, but not if that peer is a trusted proxy
(MAPI_TRUSTED_PROXIES). Then the client address is the right-most X-Forwarded-For hop
that is not a trusted proxy. Thus callers behind one load balancer keep separate limits. A
caller cannot forge its address when it sends the header itself. The multi-tenant profile
does not start without the setting (use none when clients connect to the API directly).
Sybil heuristics and the review queue¶
When a sign-up is verified, the platform looks back MAPI_SIGNUP_SYBIL_WINDOW_DAYS (30) days
for workspaces that come from the same signals:
| Flag | Signal |
|---|---|
same_ip |
the same client address (keyed digest) |
same_device |
the same browser: a random, HttpOnly mapi_device cookie that the server sets at sign-up |
same_email |
the same e-mail address |
domain_burst |
many verified sign-ups from one domain in one hour (two times the threshold). Never for the shared free-mail domains |
At MAPI_SIGNUP_SYBIL_THRESHOLD (3) earlier workspaces, the server flags the new
sign-up. The workspace works, and it waits in the review queue (auth.signup_flagged in the
audit log). With MAPI_SIGNUP_FLAGGED_SUSPEND=true, the server suspends a flagged workspace
(read-only) until an operator approves it.
Operators examine flagged sign-ups:
# the queue, newest first
curl -s -b cookies "$API/v1/admin/signups?review=flagged"
# reject: the workspace is suspended (audited, with the reason)
curl -s -b cookies -H "X-CSRF-Token: $CSRF" -X POST "$API/v1/admin/signups/$ID/review" \
-H 'Content-Type: application/json' -d '{"decision":"reject","reason":"device farm"}'
# approve: the flag is cleared, and a suspension the review caused is lifted
An approval removes only the suspensions that the review caused. It never removes a suspension that an operator set. If you reject a sign-up that was never verified, its link stops working.
Identity verification and cut line A¶
Each tenant has a verification record. GET /v1/tenants/{tenant_id}/verification answers
email_verified, identity_verified, the method and uploaded_code_allowed, with a reason
in plain words. The rule (mapi_spec.trust.uploaded_code_allowed):
| Trust tier | Uploaded code (plugins with an overlay) |
|---|---|
internal, self-hosted-trusted |
allowed |
verified |
only after identity verification |
public |
never before cut line B (MAPI_PUBLIC_CODE_UPLOAD_ENABLED, planned). After cut line B, only with identity verification |
The server enforces the rule at run admission. If a tenant cannot run uploaded code, the
server refuses its runs of uploaded code with 403 IDENTITY_VERIFICATION_REQUIRED. The audit
log records this as a denied run.create. The server always admits built-in models and
declarative specs. The plugin upload of M4 calls the same check
(mapi_server.services.verification.require_uploaded_code_allowed).
After a check outside the platform (a contract, an invoice), operators record the identity verification:
curl -s -b cookies -H "X-CSRF-Token: $CSRF" -X PUT \
"$API/v1/admin/tenants/acme/verification" -H 'Content-Type: application/json' \
-d '{"identity_verified":true,"method":"manual","reason":"signed enterprise contract"}'
The request-path database role can only read verification records. Thus no tenant session
can mark its own tenant as verified. A card or document provider connects as a
mapi_server.services.trust.IdentityVerifier. A deterministic FakeIdentityVerifier is
available for tests and drills. No vendor is integrated yet.
Bot checks¶
SignupRequest.challenge contains a bot-check token (for example Cloudflare Turnstile). The
server asks TrustServices.challenge (mapi_server.services.trust.SignupChallenge). The
default accepts all sign-ups. Then the rate limits, Cloud Armor and the Sybil review do the
work. A deployment that adds a vendor check implements the protocol.
Runbook: a sign-up wave¶
- Look at the queue (
GET /v1/admin/signups?review=flagged) and the audit log (auth.signup_rejected,auth.signup_flagged). - Decrease the limits (
MAPI_SIGNUP_PER_IP_PER_HOUR,MAPI_SIGNUP_GLOBAL_PER_HOUR), or setMAPI_SIGNUP_FLAGGED_SUSPEND=true. - Deploy the API again.
- Block the domains (
MAPI_SIGNUP_BLOCKED_DOMAINS). - At the edge, decrease
cloud_armor.rate_limits.signup_per_minutein Terraform. - Reject the flagged sign-ups.
- Suspend all other workspaces that you must stop with
PATCH /v1/admin/tenants/{id}. Public workspaces never ran uploaded code, so their runs used only built-in models. The kill switches and the spend cap still limit what they can spend. - To close sign-up fully, set
MAPI_SIGNUP_ENABLED=false.