Login Challenges
A login challenge is what happens when the password was right but the sign-in still cannot finish. Foxnox mints a short-lived token bound to one specific next step, and the BFF answers the login request with 202 instead of a session.
Why 202
A login has more than two outcomes. "Wrong password" is a 401 and "here is your session" is a 200, but "correct password, now prove you hold the second factor" is neither — the credentials were accepted, yet no session exists yet.
Returning 202 Accepted with a URL lets the BFF represent that intermediate state explicitly. The BFF chooses the required step from the public pwd row, then asks Foxnox to mint a challenge of that kind.
The Three Kinds
| Kind | Token type | Workflow page | Raised when |
|---|---|---|---|
expired-password | Expired password challenge | /password/expired | pwdExpiry is in the past |
2fa | 2FA challenge | /2fa/verify | twoFactorEnabled is true and no valid trusted-device cookie |
trusted-device | Trusted device challenge | /trusted-devices/prompt | Offered after a successful 2FA check |
Order matters. An expired password is checked before 2FA, because there is no point verifying a second factor for a credential the user is about to be forced to replace.
Login Flow
The sequence uses Gatelin as the example BFF.
The important detail is the last three steps. Completing a challenge does not create a session — Foxnox cannot, it does not issue tokens. Instead it hands the browser a login resume ticket, and the BFF trades that ticket for a session.
Mint a Challenge
Called by the BFF after a successful password check, over the internalPWD_CHALLENGES_URL. It is not a Gatelin-proxied admin route: a signed-in admin must not be able to mint a challenge for an arbitrary userId.
Foxnox only accepts the mint when that userId just passed POST /foxnox/pwd/compare (grant lasts five minutes and is single-use). trusted-device cannot be minted here — the 2FA workflow page creates that challenge in-process after a valid TOTP.
POST /foxnox/challenges
Content-Type: application/json
{
"userId": 1,
"kind": "2fa"
}Foxnox does not inspect an Authorization header on this internal endpoint; it relies on network isolation, the compare grant, and the kind allow-list.
kind must be 2fa or expired-password.
Response (201 Created):
{
"kind": "2fa",
"challenge": "9f2c1a…",
"path": "/2fa/verify",
"url": "http://localhost:8100/api/foxnox/web/2fa/verify?challenge=9f2c1a…",
"expiresAt": "2026-08-19T18:10:00.000Z"
}| Field | Description |
|---|---|
kind | The challenge kind that was minted |
challenge | The plaintext token — returned once, stored only as a hash |
path | Workflow page path, relative to the web base |
url | Absolute URL to redirect the browser to, built from WEB_PUBLIC_ORIGIN + WEB_PUBLIC_BASE |
expiresAt | When the challenge stops being valid |
Redirect the browser to url; the query parameter is named challenge, not token, which is how the pages tell a mid-login step apart from an email link.
Verify a Trusted Device
POST /foxnox/devices/verify
Content-Type: application/json
{
"userId": 1,
"deviceToken": "<value of the trusted_device cookie>"
}Response (200 OK): { "trusted": true } — see Trusted Devices.
Checking this before minting a 2FA challenge is what stops the service asking for a code on every single sign-in from the user's own laptop.
Redeem a Login Ticket
Called by the BFF when the frontend posts a resume ticket (Gatelin: POST /gatelin/sessions/resume).
POST /foxnox/login-tickets/redeem
Content-Type: application/json
{
"ticket": "b71e4d…"
}Response (200 OK):
{ "userId": 1 }Response (400 Bad Request): { "error": "Missing ticket" } or { "error": "Invalid or expired ticket" }.
Tickets last 10 minutes and have a maximum of one attempt, so redeeming is genuinely one-shot: a second call with the same ticket fails, and a reloaded resume URL is correctly rejected rather than minting a second session.
Challenge Chaining
One login can require more than one step, and each completed page mints the next challenge rather than returning to the BFF. Verifying a 2FA code, for example, consumes the 2FA challenge and redirects to the trusted-device prompt with a fresh challenge attached.
Only the final page in the chain issues the login resume ticket. From the frontend's point of view none of this is visible: it redirects once on 202 and waits for the browser to come back with ?ticket=….