Signup

How the IAM service registers new users, the full validation pipeline from rate limiting through email domain verification to password breach checks, and what happens in the database when a user is created.

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

MethodPathBody limit
POST/signup1 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:

ParameterDefaultConfig key
Hash length50 bytespassword.hashLength
Time cost4 iterationspassword.timeCost
Memory cost2^18 KiB (256 MB)password.memoryCost
SecretPepper from configpassword.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:

ColumnSource
nameFirst token from name split
last_nameRemaining tokens from name split
emailValidated, lowercased email
password_hashArgon2 hash from the previous step
remember_userFrom the request body (boolean)
terms_and_privacy_agreementFrom the request body (required true)
countryFrom the visitor record (or null if 'unknown')
cityFrom the visitor record (or null if 'unknown')
districtFrom the visitor record (or null if 'unknown')
visitor_idFrom 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:

CookieValueOptions
iatDate.now() as stringhttpOnly, secure, sameSite: strict, path: /
sessionRaw 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

StatusCondition
400Invalid JSON, schema validation failure, empty body, missing canary_id, breached password, invalid email domain, disposable email
403HTML/XSS detected in input fields
409Email already registered
429Rate 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:

  1. Bans the client IP through the Bot Detector (banIp)
  2. Marks the visitor record as is_bot = true
  3. Inserts the IP into the banned table
  4. 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:

LimiterKeyPointsWindowBlock duration
IP burstreq.ip2 / 1 second1 s15 min
IP slowreq.ip5 / 30 minutes30 min15 min
Composite burstip + email1 / 1 second1 s30 min
Composite slowip + email3 / 24 hours24 h24 h
Emailemail3 / 24 hours24 h24 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

OptionLocationTypeDescription
password.pepperRoot configstringServer-side secret mixed into Argon2 hashes
password.hashLengthRoot confignumberArgon2 output length in bytes (default: 50)
password.timeCostRoot confignumberArgon2 iteration count (default: 4)
password.memoryCostRoot confignumberArgon2 memory in KiB (default: 2^18)
jwt.refresh_tokens.refresh_ttljwtnumberRefresh token lifetime in milliseconds
jwt.refresh_tokens.domainjwtstringCookie domain for the session cookie

See Configuration for the full schema reference.

Logo