This tutorial wires a real business endpoint — “remove team member” — into sensitive-operation re-authentication. Three paths correspond to three postures, chosen by sensitivity: bulk operational actions go through policy rows (zero code), platform-hard constraints go through a declared baseline (one attribute), and money-moving operations use one-shot tickets (a planned capability; a delivered alternative is given below). The paths compose: an endpoint can carry a declared baseline while tenant policy rows narrow the audience.
Prerequisites:
- A runnable BitzOrcas API Host with Redis connected (credential and revocation-version storage require it);
- A demo account holding
identity.stepup.manage(seeded toroot-admin/tenant-admin/host-adminby default); - The feature toggle
identity.stepupenabled on the host “feature distribution” page — it is seeded off by default; while off, the whole chain has zero behavior, which is the designed rollback channel.
Outcome: calling the endpoint without a credential returns a structured 403; completing one verification lets the replayed request through; further calls within the window no longer prompt.
Prepare: toggle and catalog
Confirm the feature toggle and purpose catalog first, so “configured but not working” never surprises you.
# Start the API Host from the repository root (environment per deployment docs; Redis required).dotnet run --project src/Hosts/BitzOrcas.Api
# Query which purposes currently require step-up for the caller;# an empty array usually means the toggle is off or no row/baseline exists yet.curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ -X QUERY https://localhost:5001/api/identity/step-up/my-purposesVerification point: the Host starts without step-up errors and my-purposes returns 200. If the Host runs with no Redis configured, identity.stepup is locked off — a deliberate guard against accidental enablement without storage, not a bug.
Path 1: create a policy row in the admin page (zero code)
For “this tenant needs extra verification on this action” demands, with no compiled artifacts touched.
- Sign in as a tenant admin, open “Organization & Access Control” → the “Re-authentication” tab;
- Click “Add protection” and search the target permission code (for example
identity.user.remove) in the catalog selector — the catalog is read-only, purposes that already have a row are greyed out with a reason; - Pick “Audit only” as enforcement first (the form always suggests this), window 300 seconds, factors
totp+emailOtp, scope “everyone”; - Save. The list shows the new row tagged “tenant override”, with the last changed by/at columns filled;
- Before switching to “required”, use the observation period: query the security audit page for
StepUpAuditOnlyObservedrecords to confirm hit volume and user experience.
Equivalent management API call (for automation pipelines):
# Create a policy row: scope allUsers, window 300 seconds, audit-only enforcement;# a conflicting duplicate (same purpose and scope) returns 422 PolicyConflict.curl -s -X POST https://localhost:5001/api/identity/step-up/policies \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "purposeCode": "identity.user.remove", "enforcement": "auditOnly", "windowSeconds": 300, "factors": ["totp", "emailOtp"], "scopeType": "allUsers", "scopeValues": [], "unenrolledBehavior": "fallbackFactors" }'Verification point: a 200 response with the row projection (including id and version); the admin page lists the row; calling the member-removal endpoint without a credential then succeeds (observation), while the security audit page records StepUpAuditOnlyObserved.
Expected failure one (guardrail): set the window to 1200 and save — you get 422 Identity.StepUp.PolicyConflict whose detail names the violated field and the host ceiling value (900; the factory detail text is Chinese). Tenant rows are bounded by the host-controlled ceiling; rejections are per-field, not all-or-nothing. Fix: choose a value within the ceiling, or have a platform admin raise the ceiling on the host “security baseline” page (bounded by the 86400-second technical cap) first.
Expected failure two (duplicate row): creating another row with the same purpose and logical scope returns 422 PolicyConflict (“a policy row already exists for the same tenant, purpose and scope”). Fix: edit the existing row instead.
Path 2: declared baseline (one attribute)
For operations that must require re-authentication in every tenant. Attach RequireStepUpAttribute to the endpoint — pure metadata; enforcement stays in the global middleware:
// Minimal API endpoint with a declared baseline: the purpose defaults to the// permission code, so no separate purpose needs to be invented.group.MapDelete("/api/business/teams/{teamId}/members/{userId}", ...) .RequirePermission("identity.user.remove") // RBAC still gates first: unauthorized callers never reach step-up .WithMetadata(new RequireStepUpAttribute("identity.user.remove") { // All of the following are baseline defaults used only when no policy // row exists; tenant rows may override them within the guardrails. Mandatory = false, // platform-mandatory purposes set true: tenants cannot soften them DefaultEnforcement = StepUpEnforcement.Required, // baseline enforcement (the default is already Required) DefaultWindowSeconds = 300, // baseline window; 0 means a one-shot ticket DefaultFactors = [StepUpFactor.Totp, StepUpFactor.EmailOtp], });# Build and start, then confirm the declaration joined the frozen catalog:dotnet build src/Hosts/BitzOrcas.Api
# Verification point one: calling without a credential yields a guidance 403# whose root fields carry purpose / factors / windowSeconds.curl -s -i -X DELETE https://localhost:5001/api/business/teams/t1/members/u9 \ -H "Authorization: Bearer $USER_TOKEN" | grep -E "HTTP|type|purpose|errorCode"Expected output contains HTTP/1.1 403, type: .../problems/step-up-required, purpose: identity.user.remove, and errorCode: Identity.StepUp.GrantRequired.
Declarations are frozen at startup in the composition root: declaring the same purpose twice with inconsistent baselines fails startup — fast failure keeps “which baseline applies” from becoming guesswork. Where source-generated endpoints cannot carry endpoint metadata, the platform provides a code baseline declaration list in the composition root (src/Hosts/BitzOrcas.Api/StepUp/StepUpBaselineDeclarations.cs in the BitzOrcasVNext repository; the pilot purpose is operations.sql-masking.manage: mandatory + one-shot). Every declared purpose passes a wiring check — a real endpoint backed by the same permission code must exist, or the host refuses to start, closing the “declaration dangles and silently loses protection” hole. Endpoints without the attribute fall through to the endpoint permission code as the purpose, so “permission code as purpose” endpoints can be driven purely by policy rows with zero attributes.
Completing one verification loop
All three paths share the same loop. Email-factor example (the fallback when the user has no TOTP):
POST /api/identity/step-up/challenge HTTP/1.1Authorization: Bearer <access-token>Content-Type: application/json
{ "purpose": "identity.user.remove" }{ "challengeId": "9fJ2vQ8rT3wL5nX1cZ7bA0dK4mH6pS2yE8uG1iO5aB3", "expiresInSeconds": 120, "windowSeconds": 300, "factors": [ { "type": "emailOtp", "target": "l***@example.com" } ]}POST /api/identity/step-up/otp/send HTTP/1.1Authorization: Bearer <access-token>Content-Type: application/json
{ "challengeId": "9fJ2vQ…", "factorType": "emailOtp" }Once the code arrives, verify — stepUpToken in the response is the re-authentication credential:
POST /api/identity/step-up/verify HTTP/1.1Authorization: Bearer <access-token>Content-Type: application/json
{ "challengeId": "9fJ2vQ…", "factorType": "emailOtp", "code": "482913" }{ "stepUpToken": "Ak7Qx2Wm9Rf4Tt1Lz8Nb0Vc5Yh3Jd6Pg2Se9Ua1Wr4X=", "purpose": "identity.user.remove", "windowSeconds": 300, "expiresAt": "2026-09-02T08:15:00Z"}# Replay the business request: the credential travels in a dedicated header# (factory name X-Step-Up-Token, configurable).curl -s -X DELETE https://localhost:5001/api/business/teams/t1/members/u9 \ -H "Authorization: Bearer $USER_TOKEN" \ -H "X-Step-Up-Token: Ak7Qx2Wm9Rf4Tt1Lz8Nb0Vc5Yh3Jd6Pg2Se9Ua1Wr4X="Verification points:
- Within the window (300 s), further calls to the same purpose need no new verification — window credentials are reusable;
- In the first-party SPA all of this is automatic via the SDK interceptor; business code never sees it (see frontend integration);
- Sign out, then replay the old credential → 403
GrantInvalid(logout bumps the revocation version).
Expected failure three (rate limit): resending OTP for the same challenge inside the resend interval (factory 60 s) returns 429 Identity.StepUp.RateLimited. Fix: honor retryAfterSeconds and retry after the countdown; the dialog already implements it.
Money-moving operations: one-shot tickets (planned)
The ideal shape for money-moving operations is “verify once, execute once”: the credential is a one-shot ticket (windowSeconds: 0) consumed inline by the business command, closing the check-act gap (TOCTOU). That command-inline mode is a planned capability, not delivered yet — do not assume it exists outside this manual.
The delivered alternative: give money-moving endpoints a baseline with DefaultWindowSeconds = 0 or a one-shot policy row, so credentials die on first use; keep amount, state-machine, and idempotency-key checks inside the handler, accepting that a burned ticket costs one re-verification. The architecture arbitration for the command-inline mode is recorded in ADR 0706 of the BitzOrcasVNext repository; the full discussion of window choice is in concept: security semantics.
Wrap-up
- Commit the declared baseline together with a unit test asserting the no-credential 403 contract; see
tests/BitzOrcas.Integration.Tests/Identity/StepUpEnforcementEndpointTests.csin theBitzOrcasVNextrepository for the pattern; - Rollout order (AuditOnly before Required) and troubleshooting: rollout and operations;
- Contract details, the full error table, and configuration keys: contract reference.