Signup
The signup route creates a new user account from an email, password, and name. Before the account is created, the request passes through six layers of validation: rate limiting, schema validation, email domain verification, disposable email detection, password breach checking, and password hashing. Only after all six layers pass does the service insert a row into the users table and issue tokens.
No verification email is sent during signup. The user receives an access token and a refresh token immediately and is considered authenticated.
Route
| Method | Path | Body limit |
|---|---|---|
POST | /signup | 1 KB |
Middleware chain:
validateContentType('application/json'), express.json({ limit: '1kb' }), canary_id check, handleSignUp
The canary_id cookie must be present. This cookie is set by the Bot Detector on first contact and is used to look up the visitor record that will be linked to the new user. If the cookie is missing, the route returns 400.
See Routes for how to mount the authentication router on your Express application.
Request body
import { z } from 'zod'
const signupSchema = z.strictObject({
name: z.string().min(2).max(72), // Lowercase-transformed, XSS-sanitized
email: z.string().min(10).max(80).email(), // Lowercase-transformed, XSS-sanitized
password: z.string().min(12).max(64), // Must match password policy regex
confirmedPassword: z.string().min(12).max(64), // Must match password
rememberUser: z.literal('on').optional(), // Checkbox toggle
termsConsent: z.literal('on'), // Required acceptance
}).refine(data => data.password === data.confirmedPassword)
The password regex enforces at least one lowercase letter, one uppercase letter, one digit, and one special character, with no whitespace allowed. The name and email fields are processed through makeSafeString, which strips HTML tags and triggers an XSS ban if any are found.
z.strictObject rejects any fields not defined in the schema. Extra properties in the request body cause a 400 validation error.
The validation pipeline
The steps run in sequence. The first failure returns an error response and the controller exits.
Rate limiting (IP)
A union limiter combines a burst guard (2 points per second, blocks for 15 minutes) with a slow guard (5 points per 30 minutes, blocks for 15 minutes). Both are keyed on req.ip. If the IP has been blocked by a previous violation, the request is rejected immediately from an in-memory cache without touching the database. See Rate Limiting
Schema validation
The request body is validated against the Zod schema above. If validation fails, the controller returns 400 with the Zod error details. If the makeSafeString check detects HTML tags in the name or email fields, the controller calls handleXSS, which bans the IP through the Bot Detector, marks the visitor as a bot, and returns 403 { banned: true }.
Rate limiting (composite key)
A second union limiter is keyed on a composite of ip + email. The burst window is 1 point per second (blocks for 30 minutes) and the slow window is 3 points per 24 hours (blocks for 24 hours).
Email domain verification
The isValidDomain function splits the email address, converts the domain to ASCII with domainToASCII, and performs a DNS lookup. It checks for MX records first. If none are found, it falls back to A and AAAA record lookups. The results are cached in an LRU cache for 24 hours. If a transient DNS failure occurs (anything other than ENODATA or ENOTFOUND), the domain is allowed through to avoid blocking users during DNS outages.
Disposable email detection
The isDisposable function checks the email domain against a pre-loaded LMDB map of known disposable email providers. If the domain is found in the list, the request is rejected. The disposable email list is loaded once at startup from a local database file managed by the Shield Base CLI.
Rate limiting (email)
A final limiter is keyed on the email address alone: 3 points per 24 hours, blocks for 24 hours. This prevents repeated signup attempts with the same email regardless of IP.
Password breach check
The isPwned function checks the password against the Have I Been Pwned Passwords API using the k-Anonymity model. It SHA-1 hashes the password, sends the first 5 characters of the hash to the API, and checks whether the remaining suffix appears in the response.
On signup, a breached password blocks registration and returns 400 with a message asking the user to choose a different password. This is stricter than the login flow, where breached passwords produce an advisory warning but do not block authentication.
Results are cached at two levels: the prefix-to-suffix map is cached for 48 hours, and individual results are cached for 15 minutes.
Password hashing
The password is hashed with Argon2 using the following parameters:
| Parameter | Default | Config key |
|---|---|---|
| Hash length | 50 bytes | password.hashLength |
| Time cost | 4 iterations | password.timeCost |
| Memory cost | 2^18 KiB (256 MB) | password.memoryCost |
| Secret | Pepper from config | password.pepper |
The pepper is a server-side secret that is mixed into every hash. Even if the database is compromised, the attacker cannot crack the hashes without the pepper. See Configuration for the full password options.
User creation
After all validation passes, the controller calls createUser(canary_id, validatedData). This function:
Look up the visitor
Queries the visitors table using the canary_id cookie to retrieve the visitor's country, city, district, and visitor_id. If no visitor record exists for the cookie, the signup fails. The visitor record is created by the Bot Detector middleware on the user's first request.
Split the name
The name field is split on spaces and commas. The first token becomes the name column and all remaining tokens are joined as last_name. For example, "Alice Johnson" becomes name: 'alice', last_name: 'johnson'. Single-word names produce an empty last_name.
Insert the user row
The function inserts a row into the users table with the following columns:
| Column | Source |
|---|---|
name | First token from name split |
last_name | Remaining tokens from name split |
email | Validated, lowercased email |
password_hash | Argon2 hash from the previous step |
remember_user | From the request body (boolean) |
terms_and_privacy_agreement | From the request body (required true) |
country | From the visitor record (or null if 'unknown') |
city | From the visitor record (or null if 'unknown') |
district | From the visitor record (or null if 'unknown') |
visitor_id | From the visitor record (UUID foreign key) |
The email column has a unique constraint. If a user with the same email already exists, the database returns an ER_DUP_ENTRY error and the controller responds with 409 { error: 'E-mail already registered' }.
Issue tokens
On successful insertion, the function generates a refresh token and an access token for the new user. Both are created using the same generateRefreshToken and generateAccessToken functions described in Refresh Tokens and Access Tokens.
Response
Success (201)
{
"ok": true,
"receivedAt": "2025-01-15T10:30:00.000Z",
"accessToken": "<signed JWT>",
"accessIat": "1705312200000"
}
Two cookies are set alongside the JSON response:
| Cookie | Value | Options |
|---|---|---|
iat | Date.now() as string | httpOnly, secure, sameSite: strict, path: / |
session | Raw refresh token (64 hex bytes) | httpOnly, secure, sameSite: strict, domain from config |
The iat cookie is a client-readable timestamp that tells the frontend when the access token was issued. The session cookie carries the raw refresh token and is never readable by JavaScript.
Error responses
| Status | Condition |
|---|---|
400 | Invalid JSON, schema validation failure, empty body, missing canary_id, breached password, invalid email domain, disposable email |
403 | HTML/XSS detected in input fields |
409 | Email already registered |
429 | Rate limit exceeded on any of the three limiters |
XSS detection
The makeSafeString Zod helper runs input sanitization on the name and email fields during schema validation. If HTML tags are detected in the input, the validation pipeline triggers handleXSS, which:
- Bans the client IP through the Bot Detector (
banIp) - Marks the visitor record as
is_bot = true - Inserts the IP into the
bannedtable - Returns
403 { banned: true }
This is a permanent ban. The IP is blocked from all future requests, not just signup.
Rate limiter reference
All signup rate limiters are configurable under rate_limiters in the configuration object. The defaults are:
| Limiter | Key | Points | Window | Block duration |
|---|---|---|---|---|
| IP burst | req.ip | 2 / 1 second | 1 s | 15 min |
| IP slow | req.ip | 5 / 30 minutes | 30 min | 15 min |
| Composite burst | ip + email | 1 / 1 second | 1 s | 30 min |
| Composite slow | ip + email | 3 / 24 hours | 24 h | 24 h |
email | 3 / 24 hours | 24 h | 24 h |
The guard() function also uses in-memory LRU caches to track consecutive violations. After a configurable number of consecutive blocks, the key is escalated to a longer-duration block. See Rate Limiting for the escalation mechanics.
Configuration reference
| Option | Location | Type | Description |
|---|---|---|---|
password.pepper | Root config | string | Server-side secret mixed into Argon2 hashes |
password.hashLength | Root config | number | Argon2 output length in bytes (default: 50) |
password.timeCost | Root config | number | Argon2 iteration count (default: 4) |
password.memoryCost | Root config | number | Argon2 memory in KiB (default: 2^18) |
jwt.refresh_tokens.refresh_ttl | jwt | number | Refresh token lifetime in milliseconds |
jwt.refresh_tokens.domain | jwt | string | Cookie domain for the session cookie |
See Configuration for the full schema reference.