Skip to main content

Extending MFA Providers

The security module exposes a registry that accepts additional MFA providers declared in other modules. No changes to the security module itself are required.

Registration file​

Declare providers in your module at:

src/modules/<your-module>/security.mfa-providers.ts

The generator scans this filename and adds the export to the security bootstrap registry. Run yarn generate after creating the file.

Provider contract​

Each provider must satisfy the MfaProviderSetup interface and implement four lifecycle methods:

MethodRequiredDescription
setupYesInitiates enrollment. Returns a setupId and optional clientData for the UI.
confirmSetupYesCompletes enrollment. Returns metadata to be stored with the method record.
prepareChallengeNoSends or prepares a challenge before the user can respond (e.g. OTP delivery, push notification). Returns clientData for the challenge UI.
verifyYesValidates the user's response during sign-in or sudo. Returns a boolean.

Additional required fields:

FieldTypeDescription
typestringUnique, stable identifier for the provider (e.g. sms)
labelstringDisplay name shown in setup and challenge screens
iconstringLucide icon name
allowMultiplebooleanWhether a user can enroll more than one instance
setupSchemaZodSchemaValidates the payload passed to setup
verifySchemaZodSchemaValidates the payload passed to verify

Minimal example​

import { z } from 'zod'
import type { MfaProviderSetup } from '@saasframe/enterprise/modules/security'

const smsSetupSchema = z.object({
phoneNumber: z.string().min(1),
})

const smsVerifySchema = z.object({
code: z.string().min(1),
})

export const mfaProviders = [
{
type: 'sms',
label: 'SMS code',
icon: 'MessageSquare',
allowMultiple: false,
setupSchema: smsSetupSchema,
verifySchema: smsVerifySchema,

async setup(userId, payload) {
const input = smsSetupSchema.parse(payload)
const setupId = await createSmsSetupSession(userId, input.phoneNumber)
return {
setupId,
clientData: { maskedPhone: maskPhoneNumber(input.phoneNumber) },
}
},

async confirmSetup(userId, setupId, payload) {
const input = smsVerifySchema.parse(payload)
await confirmSmsSetup(userId, setupId, input.code)
return { metadata: { label: 'SMS code' } }
},

async prepareChallenge(userId, method) {
await sendSmsChallenge(userId, method.id)
return { clientData: { delivery: 'sms' } }
},

async verify(userId, method, payload) {
const input = smsVerifySchema.parse(payload)
return verifySmsCode(userId, method.id, input.code)
},
},
] satisfies MfaProviderSetup[]

Custom UI components​

By default the security module renders a generic code-entry UI for setup and verification. This is sufficient for most code-based providers.

To override any screen, add component handle IDs to the provider declaration:

components: {
setup: 'section:security.mfa.setup.provider:sms',
list: 'section:security.mfa.providers.list-item:sms',
details: 'section:security.mfa.provider.details:sms',
challenge: 'section:security.mfa.challenge.provider:sms',
},

Then register replacement components in your module's widgets/components.ts:

import { z } from 'zod'
import type { ComponentOverride } from '@saasframe/shared/modules/widgets/component-registry'
import SmsChallengeVerify from './components/SmsChallengeVerify'

const passthroughProps = z.object({}).passthrough()

export const componentOverrides: ComponentOverride[] = [
{
target: { componentId: 'section:security.mfa.challenge.provider:sms' },
priority: 50,
replacement: SmsChallengeVerify,
propsSchema: passthroughProps,
},
]

Custom components are appropriate when the generic code-entry flow is insufficient, for example: QR code setup, WebAuthn browser interactions, push approval with polling, or hardware token prompts.

Behavior after registration​

A registered provider is automatically available in:

  • the user's MFA setup page (/backend/profile/security/mfa)
  • the sign-in challenge panel, if the user has enrolled
  • sudo challenges when challengeMethod is mfa or auto

The provider must handle both sign-in and sudo challenge contexts. Both use the same prepareChallenge and verify methods.