Appearance
How Workflows Work
Account workflows are the server-rendered pages Foxnox serves to end users. They exist so that you do not have to build password reset, 2FA enrollment, and device management screens in every frontend you own — and so that the pages handling credentials live in the service that owns them.
They are mounted at /foxnox/web/… internally, and reached publicly at /api/foxnox/web/… through the BFF (Gatelin in the examples).
Two Families
Every workflow is driven by a token in the URL, but where that token comes from splits them into two groups that behave quite differently.
Email-driven workflows start with a user who cannot sign in. They type an email address, receive a link, and click it. The token arrives as ?token=….
| Workflow | Entry point |
|---|---|
| Password recovery | /recover |
| Account unlock | /unlock |
| Lost 2FA recovery | /account-recover |
Login-step workflows start mid-sign-in. The password was already accepted, the BFF minted a challenge, and the browser was redirected. The token arrives as ?challenge=….
| Workflow | Entry point |
|---|---|
| Two-factor authentication | /2fa/verify |
| Expired password | /password/expired |
| Trusted devices | /trusted-devices/prompt |
The different query parameter name is not cosmetic: it is how each page knows which token type to validate against, so an email link can never be used to satisfy a login challenge or vice versa.
A third, smaller group is self-service settings — pages a signed-in user visits deliberately, with no token at all: 2FA setup, security questions, and device management. These are the only workflow routes registered as protected.
The Email Pipeline
Every email-driven workflow follows the same four steps:
- Resolve the address. The submitted email is sent to
USER_SEARCH_URLto find auserId. Foxnox does not store email addresses. - Create a typed token. A random plaintext is generated, HMAC-hashed with
PWD_SECRET, and stored. Only the hash is persisted. - Build the deep link.
WEB_PUBLIC_ORIGIN+WEB_PUBLIC_BASE+ the page path +?token=<plaintext>. - Send it. A Handlebars email template rendered in the user's language and delivered over SMTP.
| Template | Token type | Link target |
|---|---|---|
pwd-reset | Password reset | /recover/reset?token=… |
account-recover | Account recovery | /account-recover/challenge?token=… |
account-unlock | Account unlock | /unlock/confirm?token=… |
When SMTP_HOST is unset, step 4 logs the payload instead of sending. That keeps tests offline but means a production deployment missing the variable fails silently.
No Account Enumeration
Every request form answers with the same confirmation page whether or not the address has an account. "Check your email" does not mean "that address exists" — it means "we are done processing your request".
This is deliberate. A form that behaved differently for known and unknown addresses would be a free tool for discovering who has an account. The cost is that a user who mistypes their address sees the same reassuring page as one who did not, which is worth knowing when reading support tickets.
Form Protections
Every workflow POST first passes through the CSRF middleware. The workflow handler then applies the relevant anti-bot checks.
CSRF. A signed double-submit check: an HttpOnly cookie named foxnox_csrf plus a matching hidden csrf field. Both must be present, identical, and carry a valid signature, or the request is rejected with 403 and no body. Tokens are valid for one hour.
Honeypot. The three email request forms (/recover, /unlock, and /account-recover) carry a decoy website field that real users never see. Anything filled in it is treated as automated. Other workflow forms do not include this field.
Timing. Every workflow form carries the timestamp it was rendered at. The handler treats a submission arriving in under 1.5 seconds, or more than an hour later, as automated.
Suspicious submissions get 204 No Content — no error, no explanation, nothing to iterate against.
Token Rules
Workflow tokens are single-use and short-lived, and both properties are enforced on lookup rather than by a cleanup job:
- Expiry — every lookup filters on
expiresAt, so an expired token stops working the moment it lapses. - Single use — successful completion stamps
verifiedAt, and verified tokens no longer match. - Attempt ceiling — each failed verification increments
attempts; passing the type'smaxAttemptskills the token even before it expires.
When a token fails any of these, the workflow shows its "link is no longer valid" page with a route back to requesting a fresh one. TTLs and attempt limits per type are listed in Tokens.
Localization
Pages and emails ship in English and French. The language is picked per request, in this order:
- A
langvalue in the query string or form body - The
Accept-Languagerequest header - English
Copy lives in web/locales/en.json and fr.json, keyed by page, so adding a language means adding a file rather than touching handlers.
Page States
Each workflow is a small set of pages rather than a single form, because there are more outcomes than "worked" and "failed":
| State | Purpose |
|---|---|
| Request | The form that starts the flow |
| Sent | Non-enumerating confirmation after submitting an email |
| Action | The page that does the real work, bound to a valid token |
| Done | Success |
| Invalid | The token was missing, expired, already used, or over its attempt limit |
Knowing these names is useful when reading logs or the view folder: web/views/pages/<workflow>/<state>.hbs maps one-to-one onto the table above.