Wocha Docs

Adaptive MFA

Risk-based multi-factor authentication with per-tenant policy, 18 assessors, and NIST-aligned step-up

Adaptive MFA triggers multi-factor authentication only when a login looks risky — not on every sign-in. Wocha combines a real-time risk engine, configurable MFA policies, device trust, and NIST-aligned factor prioritisation into a single consent-time pipeline. Unlike platforms that hide risk signals internally, Wocha exposes numeric scores, per-assessor confidence, and full audit history to your team and your Auth Actions code.


Overview

Traditional MFA is binary: every user completes a second factor on every login, or MFA is off entirely. Adaptive MFA sits between those extremes. Wocha scores each OAuth consent request against behavioural, geographic, device, and network signals. When the combined score crosses your threshold, the user is stepped up to AAL2 (or AAL3 for hardware keys). Low-risk logins proceed without friction.

What makes Wocha's implementation industry-leading:

  • Transparent scoring — numeric risk score (0.0–1.0) and per-assessor breakdown, not a black box
  • 18 consent-time assessors — bot detection (weighted sub-scoring), velocity, geo-velocity, impossible travel (city-level), device trust, device anomaly, anonymous proxy/VPN, multi-device velocity, concurrent geo sessions, account age risk, IP reputation, datacenter ASN, time-of-day baselines, phone risk, SIM-swap, SMS abuse, and more
  • Adaptive challenge ladder — graduated responses from invisible CAPTCHA through Proof-of-Work to MFA step-up, integrated with bot protection
  • Per-tenant and per-org policynever, adaptive, or always MFA with org-level overrides
  • NIST-aligned ACR/AMRurn:wocha:aal1 / aal2 / aal3 with RFC 8176 authentication method references
  • Configurable device trust TTL — known devices expire after a configurable period (default 30 days)
  • Shadow mode — score and log without enforcing, so you can tune thresholds on real traffic
  • Self-hosted parity — full adaptive MFA stack available on-premises

Architecture

At consent time (when a user approves an OAuth authorisation), Wocha runs a unified pipeline:

flowchart TD
    A[OAuth consent request] --> B[Risk engine scores request]
    B --> C{IP on allowlist?}
    C -->|yes| Z[Allow — score 0]
    C -->|no| D[Evaluate 18 assessors]
    D --> E[Pre-token Auth Actions]
    E --> F{Risk verdict}
    F -->|block| G[Reject consent]
    F -->|step_up| H{Tenant MFA policy}
    F -->|allow| H
    H -->|never| I{Org MFA required?}
    H -->|always| J[Force MFA]
    H -->|adaptive + step_up| J
    H -->|adaptive + allow| I
    I -->|yes| J
    I -->|no| K[Accept consent]
    J --> L{User enrolled?}
    L -->|yes| M[AAL2/AAL3 step-up]
    L -->|no| N[Unenrolled action]
    M --> K
    N --> K

The risk engine, MFA policy engine, device trust store, and factor prioritiser work together:

ComponentRole
Risk engine (@wocha/risk-engine)Scores the login context; returns verdict (allow, step_up, block) and per-assessor details
MFA policyDecides whether a step_up verdict (or always policy) triggers MFA enforcement
Device trustFingerprints stored in Postgres; devices older than the trust TTL are treated as unknown
Factor prioritisationWhen MFA is required, challenges the strongest enrolled factor (WebAuthn > TOTP > SMS > Email)
ACR/AMRMaps the completed session to NIST-aligned token claims

Evaluation runs under a 150ms hard deadline. Rules execute cheapest-first with early exit when the block threshold is reached.


MFA policy model

Each tenant stores an MFA policy in tenant_configs under the key mfa_policy. Three policy levels are available:

PolicyBehaviour
neverMFA is never enforced by the risk engine or tenant policy. Org-level required enforcement still applies.
adaptiveMFA is enforced when the risk verdict is step_up. Threshold strictness is controlled by adaptiveThreshold.
alwaysEvery first-party consent requires MFA (enrolled users step up; unenrolled users follow unenrolledUserAction).

Additional policy fields:

FieldDefaultDescription
gracePeriodMs24 hoursNew accounts can skip MFA enforcement during this window
deviceTrustTtlDays30Days before a known device fingerprint is treated as unknown
adaptiveThresholdmediumMaps to risk step-up threshold: low → 0.2, medium → 0.3, high → 0.5
allowedFactorstotp, webauthnWhich MFA factors can be challenged (add sms or email to enable)
preferredFactorOverride NIST priority when this factor is enrolled
ignoreRememberDeviceOnRisktrueIgnore "remember device" when risk triggers step-up
unenrolledUserActionforce_enrollmentemail_challenge, force_enrollment, or allow when MFA is required but the user has no factors

Risk assessors

Wocha evaluates 18 assessors at consent time, grouped by category. Each has a default weight (contribution to the aggregate score when triggered):

Bot detection

AssessorDefault weightWhat it detects
bot_detected0.8Weighted sub-scoring across 13 client SDK signals — WebDriver, automation globals, headless browser, canvas/WebGL/audio fingerprint anomalies, timing, plugins, UA, screen, timezone/language mismatch. Triggers at composite sub-score >= 0.5 with confidence >= 0.3.

Network and geo

AssessorDefault weightWhat it detects
ip_reputation0.6Source IP on the deny-list (abuse, anonymiser, Tor, datacenter, reputation)
impossible_travel0.5Login from a location requiring travel faster than 900 km/h. Uses city-level lat/lon coordinates (MaxMind GeoLite2-City) with fallback to 200+ country centroids. Skips when VPN/proxy detected (reduces false positives).
concurrent_geo_sessions0.4Active sessions from 2+ countries simultaneously — a strong account takeover indicator
anonymous_proxy0.35Login from anonymous proxy, VPN exit node, or Tor relay (MaxMind Anonymous IP DB)
geo_anomaly0.3Login from an unusual geographic region for this user
datacenter_ip0.25Source IP resolves to a known cloud provider ASN (AWS, GCP, Azure, etc.)
new_country0.15First login from this country for the user

Device intelligence

AssessorDefault weightWhat it detects
device_anomaly0.35Device flagged as suspicious, or fingerprint component drift (2+ hardware properties changed with the same hash, suggesting spoofing)
multi_device_anomaly0.35+ distinct devices used within 24 hours for the same user
unknown_device0.2First-time device fingerprint, suspicious device, or device past trust TTL

Behavioural

AssessorDefault weightWhat it detects
credential_stuffing0.6High rate of failed login attempts for the user or IP (8+ in 1 hour)
velocity_anomaly0.4Unusual login frequency (5+ logins in 5 min, 20+ in 1 hour, 10+ token issuances in 5 min)
account_age_risk0.15Account created within the last 24 hours
time_pattern_anomaly0.1Login hour falls outside the user's 90-day behavioural baseline (< 5% of historical logins)

Phone and SMS

AssessorDefault weightWhat it detects
sim_swap_detected0.5SIM card swapped within the last 7 days (Twilio Lookup or context signal)
sms_abuse0.4Excessive SMS OTP requests to a phone number (10+ in 1 hour)
phone_risk0.3VoIP/virtual number, country mismatch with geo-IP, or shared number across accounts

Per-rule weights, enable/disable toggles, and thresholds are configurable per tenant in the Console or via the Customer API. Rules are evaluated cheapest-first with a 150ms hard deadline and early exit when the block threshold is reached.

When a user has a phone number on their identity or is enrolling SMS MFA, three assessors evaluate phone-specific risk:

SignalAssessorDetails
VoIP detectionphone_riskFlags virtual/VoIP numbers via prefix list or Twilio Lookup is_voip signal
Country mismatchphone_riskPhone country code does not match geo-IP country of the login
Shared numberphone_riskSame E.164 number registered on multiple identities
SIM swapsim_swap_detectedSIM card swapped within 7 days (Twilio Lookup v2; advisory, not blocking by default)
SMS OTP velocitysms_abuseMore than 10 OTP send requests to a phone in 1 hour

SIM-swap detection requires Twilio credentials configured in SMS risk settings (sim_swap_check: true). When the lookup store is unavailable, the assessor skips gracefully without affecting other rules.

SMS step-up challenges the user's enrolled phone via the Wocha SMS Gateway. When sms is the preferred or only enrolled factor, the auth app redirects to /sms-verify instead of native Kratos AAL2. See the SMS MFA API reference for the step-up flow.


Confidence model

Each assessor returns an ordinal confidence level alongside the numeric weight:

Assessor stateConfidenceMeaning
Not triggeredhighThis signal does not indicate risk
Triggered, weight ≥ 0.5lowStrong risk signal
Triggered, weight ≥ 0.2mediumModerate risk signal
Not evaluated (budget exhausted)neutralAssessor did not run in time

The overall assessment also maps the verdict to confidence: allowhigh, step_upmedium, blocklow.

Auth Actions receive the full riskAssessment object:

api.access.riskAssessment = {
  score: 0.45,
  confidence: "medium",
  assessments: {
    unknown_device: { confidence: "medium", triggered: true, weight: 0.2, code: "unknown_device", details: {} },
    bot_detected: { confidence: "high", triggered: false, weight: 0.8, code: "bot_detected", details: {} },
  },
};

Configuration

Console UI

Navigate to Settings → Security → MFA Policy to configure:

  • Policy selector (Never / Adaptive / Always)
  • Grace period and device trust TTL sliders
  • Adaptive threshold (Low (strict) / Medium / High (lenient))
  • Allowed factors checkboxes (TOTP, WebAuthn, SMS, Email)
  • Preferred factor dropdown
  • Unenrolled user action
  • "Ignore remember device on risk" toggle

Navigate to Settings → Risk & Fraud for risk engine settings:

  • Master enable/disable and shadow mode
  • Step-up and block thresholds
  • Per-rule weight sliders
  • IP allowlist (CIDR ranges)

The Security → Risk Dashboard shows per-assessor breakdowns, confidence distribution, MFA trigger rate, and top triggered rules.

Customer API

# Risk configuration
curl -X GET "https://TENANT.api.wocha.ai/v1/customer/risk/config" \
  -H "Authorization: Bearer wocha_mgmt_..."
 
# MFA policy (stored in tenant_configs)
curl -X PUT "https://TENANT.api.wocha.ai/v1/customer/config/mfa_policy" \
  -H "Authorization: Bearer wocha_mgmt_..." \
  -H "Content-Type: application/json" \
  -d '{
    "policy": "adaptive",
    "adaptive_threshold": "medium",
    "device_trust_ttl_days": 30,
    "allowed_factors": ["totp", "webauthn"]
  }'

Shadow mode

Enable shadow mode before turning on enforcement. The engine scores and logs every consent decision without blocking or stepping up. Review events in the Console risk dashboard or via GET /v1/customer/risk/events, then tune thresholds before disabling shadow mode.


Per-Organisation Policies

Organisations inherit the tenant MFA policy by default and can override specific fields to be stricter. See the full Organisation security policies guide for password and session policies.

MFA enforcement levels

Set mfaEnforcement per organisation (Console Security tab or Customer API):

ValueBehaviour
optionalMFA only when tenant policy, adaptive risk, or org mfaPolicy override requires it
recommendedUnenrolled users see an interstitial (/mfa-recommended) with "Set up now" and "Skip for now"; dismissal suppresses the prompt for 7 days. Enrolled users at AAL1 are stepped up.
requiredAlways require MFA for members of this org — no grace period

Org-level required enforcement runs after tenant policy and can be stricter than a never tenant policy.

MFA policy override

Orgs can set their own mfaPolicy (never / adaptive / always) independently of enforcement level:

  • mfaEnforcement — who must have MFA enrolled
  • mfaPolicy — when to challenge at consent time

Example: tenant adaptive + org always + org required → all org members always face MFA, regardless of risk score.

Per-org factor configuration

When mfaEnforcement is optional, orgs can still override allowedFactors and preferredFactor. Allowed factors must be a subset of the tenant's allowed factors. These overrides apply when adaptive MFA triggers step-up for that org's members.

curl -X PUT "https://TENANT.api.wocha.ai/v1/customer/organisations/org_abc/security-policy" \
  -H "Authorization: Bearer wocha_mgmt_..." \
  -H "Content-Type: application/json" \
  -d '{
    "mfaEnforcement": "recommended",
    "mfaPolicy": "adaptive",
    "allowedFactors": ["totp", "webauthn"],
    "preferredFactor": "webauthn"
  }'

ACR and AMR

Wocha issues NIST-aligned Authentication Context Class Reference (ACR) and Authentication Methods References (AMR) claims in ID and access tokens.

ACR URNAssurance levelTypical factors
urn:wocha:aal1Single factorPassword, passkey, or social login
urn:wocha:aal2Two factorsAbove + TOTP, SMS, or email OTP
urn:wocha:aal3Hardware-boundAbove + hardware WebAuthn security key

AMR values follow RFC 8176:

AMRMethod
pwdPassword
otpTOTP or email OTP
smsSMS OTP
hwkHardware WebAuthn key
swkSoftware passkey
fedFederated (OIDC/social)
mfaComposite — two or more distinct factors

Request step-up by passing acr_values in the OAuth authorise request. See the Step-up authentication guide for code examples.


Auth Actions

Pre-token Auth Actions run after risk scoring and before MFA enforcement. Use riskAssessment to make custom decisions and challengeMfa() to force a step-up:

exports.onExecutePreToken = async (event, api) => {
  const risk = api.access.riskAssessment;
  if (!risk) return;
 
  // Block high-risk admin API access
  if (risk.score >= 0.7 && event.client.client_id === "admin-dashboard") {
    api.access.deny("High-risk login blocked for admin access");
    return;
  }
 
  // Force MFA for sensitive scopes regardless of score
  if (event.requested_scopes?.includes("payments:write")) {
    api.access.challengeMfa("webauthn");
  }
 
  // Enrich token with risk metadata
  api.accessToken.setCustomClaim("risk_score", risk.score);
  api.accessToken.setCustomClaim("risk_confidence", risk.confidence);
};

challengeMfa(factor?) sets a flag the consent processor reads. When called, the user is redirected to AAL2 step-up even if the risk verdict was allow.


SDK helpers

Check ACR levels in your application middleware with helpers from @wocha/nextjs and @wocha/express:

import { requireAal2, requireAal3 } from "@wocha/nextjs";
 
// Next.js route handler
export async function POST(req: Request) {
  const session = await getSession();
  if (!requireAal2(session)) {
    return redirectToStepUp("urn:wocha:aal2");
  }
  // Sensitive operation...
}
import { requireAal3 } from "@wocha/express";
 
app.delete("/api/admin/keys/:id", (req, res, next) => {
  if (!requireAal3(req.wocha.session)) {
    return res.status(403).json({ error: "hardware_mfa_required" });
  }
  next();
});
HelperReturns true when
requireAal2(token)acr is urn:wocha:aal2, urn:wocha:aal3, or legacy aal2
requireAal3(token)acr is urn:wocha:aal3 or legacy aal3