Skip to main content

Per-App MFA Policies

ZewstID lets you tune multi-factor authentication on a per-application basis. Some apps need MFA on every sign-in (banking, healthcare). Some only need it when something looks risky (most consumer apps). And some don't need it at all.

The MFA policy engine combines three signals to make a decision for each login:

  1. Portal detection — first-party ZewstID portals always require MFA.
  2. Per-app policy — the policy you set in the Developer Portal:
    always
    ,
    smart
    , or
    never
    .
  3. Risk assessment — device fingerprint, IP reputation, location, and travel velocity.

This guide walks through each policy, how Smart MFA decides when to challenge, how to configure it, and how to use step-up authentication for sensitive actions.


The Three Policies

Every application has an

mfaPolicy
field. The default is
smart
, which is what you want for most apps.

always
— MFA on Every Sign-In

Every sign-in triggers MFA. The user enters their password (or completes magic link / OTP / OAuth), and immediately gets prompted for a second factor — TOTP code or push approval.

Use this for:

  • Banking, payments, financial apps
  • Healthcare and patient data
  • Internal admin tools that touch production
  • Anything where the cost of a single account compromise is catastrophic

Trade-off: higher friction. Users complete MFA on every device, every session, every day. Expect some support tickets about lost authenticators.

smart
— Risk-Based MFA (Default)

MFA only triggers when something looks off. If the user is on a known device, on a known IP, in their usual region, and recent risk signals are clean — MFA is skipped. Otherwise, they're prompted.

Use this for:

  • Most consumer-facing apps
  • SaaS dashboards
  • Anything where the goal is "good enough security with minimal friction"

Trade-off: users on new devices or traveling will get challenged. That's the point — the system trades occasional prompts for low day-to-day friction.

never
— MFA is Never Triggered During Sign-In

Users can still enroll TOTP and push devices in their account settings, but those methods are never required to sign in.

Use this for:

  • Low-sensitivity apps where you've explicitly decided MFA isn't needed
  • Read-only public-data apps
  • Apps where you've moved authentication to another layer (e.g. you're behind a VPN)

Warning: even with

never
, suspicious sign-ins are still logged. The risk engine just won't block or challenge them. If you need to relax MFA for a specific case, prefer
smart
and tune your risk thresholds instead.


ZewstID Portals Always Require MFA

The four first-party portals always enforce MFA, regardless of any per-app policy:

  • user-portal
    (account.zewstid.com)
  • developer-portal
    (developers.zewstid.com)
  • admin-dashboard
    (admin.zewstid.com)
  • org-portal
    (orgs.zewstid.com)

This is a hard security boundary. These portals control:

  • The user's primary identity, password, and enrolled MFA methods
  • OAuth client credentials, API keys, and webhook secrets
  • Tenant-wide settings: SSO, SCIM, branding, domain verification
  • Platform admin operations

A compromise at the portal layer is a compromise of every downstream app. Allowing developers to disable MFA for these portals would let one weak account undo the security posture of every app and tenant they own. So it's not configurable.

If your users complain about prompts on the portal, the answer is enroll a passkey or push device for low-friction MFA — not weaken the policy.


How Smart MFA Decides

When

smart
is selected, the policy engine calls the risk scoring service before granting the session. The risk service looks at:

SignalBehavior
New device or browser fingerprintMFA required
New IP addressMFA required
New geographic locationMFA required
Trusted device expired (30 days since last MFA on that device)MFA required
Impossible travel (e.g. NYC then Tokyo 30 min later)MFA required
Risk score ≥ 30MFA required
Risk score ≥ 80Login blocked entirely
Known device + known IP + low risk scoreMFA skipped

The decision is reflected in the response from

POST /api/v1/auth/mfa/status/check
:

{ "mfaRequired": true, "mfaSessionId": "a1b2c3d4...", "methods": { "totp": true, "push": true } }

If the risk score is critical (≥ 80), the engine returns a 403 instead and writes an audit log entry:

{ "error": "access_denied", "error_description": "Login blocked due to suspicious activity. Please try again later." }

Trusted Devices

After a successful MFA verification, the device fingerprint is marked as trusted for that user for 30 days. Within that window, future sign-ins from the same device + IP combination skip MFA (assuming risk stays low).

If the device hasn't been seen in 30 days, it expires and the next sign-in re-challenges. This is intentional — a stolen laptop you forgot about shouldn't get a forever pass.

Fail-Safe Behavior

If the risk service is temporarily unavailable, Smart policy defaults to requiring MFA. We'd rather inconvenience a user than silently grant access during an outage.


Configuring the Policy

In the Developer Portal

  1. Sign in to developers.zewstid.com
  2. Open Applications → Your App → Settings
  3. Scroll to MFA Policy
  4. Pick a radio option: Smart (Recommended), Always, or Never
  5. Click Save Policy

The policy is cached in Redis for 5 minutes, so changes take effect within that window. To force an immediate change, restart the API gateway or wait 5 minutes.

Via the API

You can also update the policy programmatically:

curl -X PATCH https://api.zewstid.com/api/v1/applications/$APP_ID \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"mfaPolicy": "always"}'

Valid values:

"smart"
,
"always"
,
"never"
. Any other value is rejected with a 400.


Step-Up Authentication

For sensitive actions mid-session — payments, data exports, password changes, deleting resources — you can require a fresh MFA verification even if the user is already signed in. This is called step-up authentication.

How It Works

recordMFA()
writes a Redis key (
user:{id}:mfa:last
) for 30 minutes after every successful MFA verification. The step-up endpoint checks that key:

  • If the user MFA'd within the last 5 minutes, step-up is auto-approved (no prompt).
  • Otherwise, step-up returns a fresh MFA session ID, and the user must verify again.

Request a Step-Up

curl -X POST https://api.zewstid.com/api/v1/auth/mfa/step-up \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"action": "payment"}'

The

action
field is required and is logged for audit purposes. Use a short kebab-case identifier like
payment
,
data-export
,
change-password
,
delete-account
.

Response — Recently MFA'd

{ "stepUpRequired": false, "verified": true, "reason": "MFA was recently completed" }

You can proceed with the sensitive action immediately.

Response — Step-Up Required

{ "stepUpRequired": true, "mfaSessionId": "f0e1d2c3...", "methods": { "totp": true, "push": true }, "expiresIn": 300 }

Your client should then prompt the user to complete MFA, just like it does on initial sign-in.

Verify the Step-Up

Use the same

/mfa/verify
endpoint as the sign-in flow:

curl -X POST https://api.zewstid.com/api/v1/auth/mfa/verify \ -H "Content-Type: application/json" \ -d '{ "mfaSessionId": "f0e1d2c3...", "method": "totp", "code": "123456" }'

For push verification, initiate the challenge first via

/mfa/challenge/push
, then submit the
challengeId
.

On success:

{ "verified": true, "userId": "user-uuid" }

The user is now considered freshly MFA'd for another 30 minutes.

When to Use Step-Up

Use step-up whenever the cost of an action is meaningfully higher than reading data:

  • Payments: before charging a card, transferring money, or initiating a payout
  • Data exports: before generating a full account export (GDPR-style)
  • Settings changes: before changing email, password, or disabling MFA
  • Destructive actions: before deleting an account, an organization, or production resources
  • API key generation: before issuing new credentials

Don't use step-up for routine reads or low-risk writes — it just trains users to click through prompts.


Risk-Based Blocking

Smart MFA can do more than challenge — for high enough risk scores, it blocks the login entirely. This is for cases where MFA alone isn't enough signal:

  • Brand-new device, brand-new IP, brand-new country
  • Impossible travel from the last sign-in
  • IP on known threat intel feeds
  • Repeated failed attempts from the same fingerprint

When the risk score crosses 80, the engine returns:

{ "blocked": true, "reason": "Login blocked: new device, new country, impossible travel" }

The API responds with HTTP 403 and the user sees a generic "suspicious activity" message. An audit log entry is written so you can review blocks in the admin dashboard.

Blocked users can recover by:

  1. Signing in from a known device/IP (e.g. their phone on home Wi-Fi)
  2. Going through the standard account recovery flow (email verification + password reset)

Rolling Out a Policy Change

If you're moving an existing app from

smart
to
always
, expect:

  • Users on trusted devices will suddenly see MFA prompts on next sign-in
  • Support volume around lost authenticators may spike for 1–2 weeks
  • Push device adoption (if enabled) typically increases

A reasonable rollout plan:

  1. Announce the change to users 2 weeks ahead
  2. Encourage passkey or push device enrollment in the user portal
  3. Flip the policy
  4. Monitor MFA failure rate for the first 48 hours

Going the other direction (

always
smart
) is less disruptive — users just see fewer prompts.


Audit and Observability

Every policy decision is logged at the

info
level on the API gateway:

  • MFA required: ZewstID portal
    — first-party portal hit
  • MFA required by risk assessment
    — Smart policy challenged
  • MFA skipped: low risk
    — Smart policy allowed through
  • Login blocked by risk assessment
    — Smart policy blocked (warn level)
  • MFA session created
    — session generated, user is now in MFA flow
  • MFA verification successful
    — user completed MFA
  • Step-up MFA session created
    — step-up was requested

Logs include

userId
,
clientId
,
policy
,
reason
, and (where relevant)
riskScore
. Pipe them into your SIEM or grep them in Loki / CloudWatch.


Common Questions

"Why does the policy take 5 minutes to update?"

The policy is cached in Redis with a 300-second TTL to avoid hitting the developer portal database on every login. If you need an immediate change, evict the cache key

mfa_policy:{clientId}
directly or restart the API gateway.

"Can I have different policies for different user groups?"

Not with

mfaPolicy
alone — the field is per-app, not per-user. For per-user MFA enforcement, use RBAC to gate sensitive routes and require MFA there via step-up authentication.

"What if a user has no MFA methods enrolled?"

The engine returns

requireMFA: false
regardless of policy — there's no second factor to challenge with. To force enrollment, gate sign-in or specific routes on
mfaEnabled === true
from
GET /api/v1/auth/mfa/status
and redirect to enrollment if false.

"Does
never
disable enrollment too?"

No. Users can still enroll TOTP, push, and backup codes. The

never
policy only affects whether MFA is required to sign in.

"How is push fatigue handled?"

The

/mfa/challenge/push
endpoint enforces a max of 3 push challenges per user per minute. Beyond that, the user gets a 429 and has to wait. This prevents push bombing attacks where an attacker spams a user with prompts hoping they tap "approve" by accident.


Next Steps

  • MFA Comparison — TOTP vs Push vs Backup Codes — pick the right enrollment options for your users
  • M2M Authentication — service accounts and machine-to-machine auth (no MFA)
  • RBAC & Roles — gate sensitive routes by role and combine with step-up
  • Push Authentication — implement Okta Verify-style push for the lowest-friction MFA experience

Was this page helpful?

Let us know how we can improve our documentation