Every API request is authenticated before it reaches a handler. On top of that baseline, sensitive money and clinical actions demand a fresh, strong factor through step-up re-authentication. This page documents how a session is issued, how to elevate it, and how to enroll the second factors — TOTP MFA and WebAuthn passkeys — that step-up verifies against. This is the sandbox surface — real logic, zero live data. Production access is a separate approval.
Three layers, in order: every request is authenticated before it reaches a handler, a session that scopes every call to your organization, and a short-lived step-up claim that gates the money and clinical paths.
Authenticated baseline. Every API request is authenticated before it reaches a handler, so a missing or invalid session is rejected before any route logic runs.
Organization scoping. Requests are automatically scoped to your organization from your credentials — you never pass an organization or clinic identifier in the body or a param, and cross-organization access is refused.
Step-up for sensitive actions. Being signed in is not enough to move money or write to a chart. Those routes require a short-lived, organization- and user-bound step-up claim. You obtain that claim by re-verifying a strong factor at POST /api/auth/step-up. The claim rides as an httpOnly cookie for browsers and can also be passed as the x-shtegmed-step-up header for service-to-service callers.
Fail closed. Every endpoint here refuses loudly rather than degrading. When step-up signing or the WebAuthn relying party is unconfigured, the endpoint returns a typed 503 and does no work — it never mints an unusable token or persists an unverified credential.
Sign-in issues a session cookie. Subsequent API calls carry that cookie; step-up-gated calls also carry the step-up claim.
Sessions have an 8-hour lifetime and are set as an httpOnly cookie on successful sign-in. Browser clients send it automatically; server-side callers forward the session cookie on each request.
/api/auth/*; identifies the user and their organization. Required on every request.shtegmed-step-up httpOnly cookie, or the x-shtegmed-step-up header, carrying the short-lived token from /api/auth/step-up. Required on money and clinical writes.x-correlation-id and it is echoed back for tracing across the auth, gate, and settlement surfaces.Every path below is available in the sandbox. Nothing here simulates success.
/api/auth/*Available in sandboxThe public sign-in entrypoint. Fetch a CSRF token from GET /api/auth/csrf, then post the credentials to /api/auth/callback/credentials. On success the session cookie is set; on failure it returns an error (invalid credentials, account locked after repeated failures, or MFA required). When the account has MFA enabled, include the current TOTP or a backup code as mfaCode in the same request — sign-in verifies it inline.
| Name | Type | In | Required | Description |
|---|---|---|---|---|
csrfToken | string | body | Required | CSRF token from GET /api/auth/csrf. Required for the sign-in POST. |
email | string | body | Required | The user’s email address. |
password | string | body | Required | The user’s password. Verified with a constant-time hash compare. |
mfaCode | string | body | Optional | Current TOTP code or a single-use backup code. Required when the account has MFA enabled. |
# 1) Get a CSRF token (and its cookie) from the sign-in endpoint
curl -s https://your-sandbox-origin.example/api/auth/csrf \
-c cookies.txt
# 2) Post credentials to the sign-in callback.
# On success the session cookie is written into cookies.txt.
curl -s -X POST \
https://your-sandbox-origin.example/api/auth/callback/credentials \
-b cookies.txt -c cookies.txt \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "csrfToken=CSRF_TOKEN_FROM_STEP_1" \
--data-urlencode "email=clinician@practice.example" \
--data-urlencode "password=••••••••" \
--data-urlencode "mfaCode=123456" \
--data-urlencode "json=true"// Fetch a CSRF token, then post credentials to the sign-in callback.
const csrf = await fetch('/api/auth/csrf', { credentials: 'include' })
.then((r) => r.json());
const res = await fetch('/api/auth/callback/credentials', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
csrfToken: csrf.csrfToken,
email: 'clinician@practice.example',
password: '••••••••',
mfaCode: '123456', // omit if the account has no MFA
json: 'true',
}),
});
if (!res.ok) {
// e.g. "Invalid credentials", "MFA Code required", "Account locked ..."
throw new Error('Sign-in failed');
}
// On success the httpOnly session cookie is set; subsequent /api/* calls are authenticated.| Status | Meaning |
|---|---|
401 | Invalid credentials, or an invalid / missing MFA code. |
423 | Account temporarily locked after repeated failed attempts (surfaced as a sign-in error). |
503fail-closed | Fail-closed: an auth dependency is unconfigured. No session is issued and no credential is trusted. |
/api/auth/step-uporg sessionAvailable in sandboxThe caller is already authenticated. This endpoint re-verifies a strong factor — the account's MFA TOTP, a single-use backup code, or a password re-entry — and, on success, mints a short-lived, tenant- and user-bound step-up claim. It is additive elevated access with heightened audit; it never bypasses tenant or clinic scoping. When factor is omitted, it defaults to MFA if the account has MFA enabled, otherwise to password. A per-user rate limit guards against factor brute force, and every attempt — success or failure — is written to the audit log.
| Name | Type | In | Required | Description |
|---|---|---|---|---|
factor | 'mfa' | 'password' | body | Optional | Which factor to re-verify. Defaults to mfa when the account has MFA enabled, else password. |
token | string | body | Optional | Current TOTP code or a single-use backup code. Required when factor is mfa. |
password | string | body | Optional | The account password. Required when factor is password. |
| Field | Type | Description |
|---|---|---|
ok | boolean | true when the factor verified. |
stepUpToken | string | Signed, short-lived step-up token. Pass it as the x-shtegmed-step-up header for non-cookie callers; browsers can rely on the httpOnly cookie set alongside it. |
expiresAt | string (ISO-8601) | When the step-up claim expires. |
ttlSeconds | number | Lifetime of the claim in seconds. |
curl -s -X POST \
https://your-sandbox-origin.example/api/auth/step-up \
-b cookies.txt -c cookies.txt \
-H "Content-Type: application/json" \
-H "x-correlation-id: 6f1c…" \
-d '{ "factor": "mfa", "token": "123456" }'{
"ok": true,
"stepUpToken": "su_9f2a…",
"expiresAt": "2026-07-14T18:42:00.000Z",
"ttlSeconds": 300
}// 1) Re-verify the factor. The httpOnly step-up cookie is set on success.
const res = await fetch('/api/auth/step-up', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ factor: 'mfa', token: '123456' }),
});
if (!res.ok) throw new Error('Re-authentication failed'); // 401 / 429 / 503
const { stepUpToken } = await res.json();
// 2) The browser now carries the step-up cookie automatically. Service-to-service
// callers instead forward the token as a header:
await fetch('/api/settlement/console/inst_123/execute', {
method: 'POST',
headers: { 'x-shtegmed-step-up': stepUpToken },
credentials: 'include',
});| Status | Meaning |
|---|---|
400 | Malformed body, or a factor the account cannot satisfy (e.g. mfa requested but not enabled). |
401 | The factor did not verify. No step-up claim is issued. |
404 | The authenticated user could not be found. |
429 | Too many attempts, or the account is temporarily locked. |
503fail-closed | Fail-closed: step-up signing is not configured. No token is minted and no network call is made. |
/api/auth/mfaorg sessionAvailable in sandboxTOTP MFA enrollment for the authenticated user. Provisioning is a two-call ceremony: GET /api/auth/mfa generates a fresh secret and an otpauth:// URL to render as a QR code, then POST with action: "enable" confirms the user can produce a valid code before the secret is stored. The secret is sealed at rest, and enabling returns one-time backup codes. Post action: "disable" with a valid code to turn MFA off. The MFA factor enrolled here is exactly what step-up and sign-in verify against.
| Name | Type | In | Required | Description |
|---|---|---|---|---|
action | 'enable' | 'disable' | body | Required | enable confirms and stores a new secret; disable turns MFA off. |
secret | string | body | Optional | The secret from GET /api/auth/mfa. Required for action=enable. |
token | string | body | Required | A current TOTP code proving the authenticator is set up. Required for both actions. |
| Field | Type | Description |
|---|---|---|
ok | boolean | true when MFA was enabled. |
backupCodes | string[] | One-time backup codes. Shown once — store them securely. |
# 1) Generate a secret + otpauth URL to render as a QR code.
curl -s https://your-sandbox-origin.example/api/auth/mfa \
-b cookies.txt
# 2) Confirm a code the authenticator produced and store the secret.
curl -s -X POST \
https://your-sandbox-origin.example/api/auth/mfa \
-b cookies.txt \
-H "Content-Type: application/json" \
-d '{ "action": "enable", "secret": "JBSWY3DP…", "token": "123456" }'// Assumes you fetched { secret, otpauthUrl } from GET /api/auth/mfa
// and rendered otpauthUrl as a QR code for the user to scan.
const res = await fetch('/api/auth/mfa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ action: 'enable', secret, token: '123456' }),
});
if (!res.ok) throw new Error('Invalid token'); // 400 when the code does not verify
const { backupCodes } = await res.json();
// Display backupCodes ONCE and prompt the user to save them.| Status | Meaning |
|---|---|
400 | Missing secret/token, an invalid code, or an unknown action. |
404 | The authenticated user could not be found. |
503fail-closed | Fail-closed: an MFA dependency is unconfigured. No secret is stored and MFA state is unchanged. |
/api/auth/webauthn/registerorg sessionAvailable in sandboxA FIDO2 / WebAuthn registration ceremony for the already-authenticated caller, expressed as two phases on one endpoint discriminated by step. Post { step: "options" } to receive PublicKeyCredentialCreationOptions and a short-lived, user- and purpose-bound challenge cookie; feed those into navigator.credentials.create(...). Then post { step: "verify", response } with the authenticator's attestation. Only a verified attestation persists a credential — an unverified response is never stored, and the spent challenge cookie is always cleared.
| Name | Type | In | Required | Description |
|---|---|---|---|---|
step | 'options' | 'verify' | body | Required | options starts the ceremony; verify completes it. |
response | RegistrationResponseJSON | body | Optional | The attestation from navigator.credentials.create(). Required when step=verify. |
shtegmed-webauthn-reg | cookie | header | Optional | The httpOnly challenge cookie set by the options phase. Sent automatically by the browser on the verify call. |
| Field | Type | Description |
|---|---|---|
ok | boolean | true when the attestation verified. |
credentialId | string | Identifier for the newly stored passkey. |
deviceType | string | Authenticator device type reported by the ceremony. |
backedUp | boolean | Whether the credential is backed up (e.g. a synced passkey). |
# Phase 1 — get options + the httpOnly challenge cookie.
curl -s -X POST \
https://your-sandbox-origin.example/api/auth/webauthn/register \
-b cookies.txt -c cookies.txt \
-H "Content-Type: application/json" \
-d '{ "step": "options" }'import { startRegistration } from '@simplewebauthn/browser';
// Phase 1 — options (sets the challenge cookie).
const optRes = await fetch('/api/auth/webauthn/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ step: 'options' }),
});
if (!optRes.ok) throw new Error('WebAuthn unavailable'); // 503 when the RP is unconfigured
const { options } = await optRes.json();
// The authenticator creates the credential.
const response = await startRegistration(options);
// Phase 2 — verify (the challenge cookie is sent automatically).
const verifyRes = await fetch('/api/auth/webauthn/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ step: 'verify', response }),
});
if (!verifyRes.ok) throw new Error('Registration verification failed'); // 400 / 401
const { credentialId } = await verifyRes.json();| Status | Meaning |
|---|---|
400 | Malformed body, or the registration challenge is missing or expired. |
401 | The attestation did not verify. No credential is persisted; the challenge cookie is cleared. |
429 | Too many registration attempts. |
503fail-closed | Fail-closed: the WebAuthn relying party is not configured. The ceremony never starts. |
When a dependency is unconfigured, an authentication endpoint returns a typed 503 and does no work — it does not mint a token you cannot use, and it does not persist an unverified factor. Production never simulates a successful sign-in, a granted step-up, or a stored credential. Each factor documented here runs on real logic in the sandbox.