Custom MFA factor: send a second factor through any channel_
Appwrite now supports a custom MFA factor. Appwrite generates and verifies the code, and your function delivers it through WhatsApp, a voice call, or any provider you choose.

Appwrite Authentication supports email, phone (SMS), and TOTP authenticator apps as second factors. Those cover most applications, but not all of them. A fintech application wants WhatsApp, because SMS delivery is unreliable in its market. A logistics company wants a voice call to a landline. An enterprise wants its own internal push system, because the security team does not accept a third-party channel.
Until now, Appwrite Authentication did not support those channels.
The custom MFA factor changes that. Appwrite generates the code and verifies it. You decide how the code reaches the user.
What the custom factor does
The custom factor is a fifth value for the factor parameter of a multi-factor challenge, next to email, phone, totp, and recoveryCode.
When your application creates a challenge with the custom factor, Appwrite generates a six-digit code, encrypts it, and stores it. Appwrite sends no email and no SMS. A new server-only endpoint gives that code to your backend, and your backend sends it through your channel. Verification then works exactly like every other factor.
Custom token login already uses the same pattern for sign-in. The custom factor applies that pattern to the second factor.
Set up the function
The delivery step needs a server. An Appwrite Function is the simplest option, and it removes the need to store an API key.
- In the Appwrite Console, open Functions.
- Create a function with the Node.js or Rust runtime.
- Open the Settings tab of the function.
- Under Scopes, enable
users.read. - Under Execute access, add the
usersrole. Signed-in users can then call the function.
The scope matters. Appwrite passes a temporary API key to every execution in the x-appwrite-key header, and that key carries the scopes of the function. Your function reads the challenge code with that key, and you do not store a permanent secret in an environment variable.
The flow
Four calls, in this order.
- Client.
account.createMFAChallenge({ factor: 'custom' }) - Client.
functions.createExecutionwith the challenge ID - Function.
users.getMFAChallenge(userId, challengeId) - Client.
account.updateMFAChallenge({ challengeId, otp })
There is no event trigger for challenge creation. Your application calls the function. The function then reads the code. Use createExecution, and not the function domain. createExecution puts the ID of the signed-in user in the x-appwrite-user-id header. A direct call to the function domain leaves that header empty.
Create the challenge
Call this after the user completes the first factor.
import { Client, Account, AuthenticationFactor } from "appwrite";
const client = new Client()
.setEndpoint('https://<REGION>.cloud.appwrite.io/v1')
.setProject('<PROJECT_ID>');
const account = new Account(client);
const challenge = await account.createMFAChallenge({
factor: AuthenticationFactor.Custom
});
import 'package:appwrite/appwrite.dart';
import 'package:appwrite/enums.dart' as enums;
MfaChallenge challenge = await account.createMFAChallenge(
factor: enums.AuthenticationFactor.custom,
);
let challenge = try await account.createMFAChallenge(
factor: .custom
)
val challenge = account.createMFAChallenge(
factor = AuthenticationFactor.CUSTOM
)
The response carries the challenge ID and the expiry time. It carries no code.
Call the function
import { Functions } from "appwrite";
const functions = new Functions(client);
await functions.createExecution({
functionId: 'send-mfa-code',
body: JSON.stringify({ challengeId: challenge.$id }),
async: false
});
Execution execution = await functions.createExecution(
functionId: 'send-mfa-code',
body: jsonEncode({'challengeId': challenge.$id}),
xasync: false,
);
let execution = try await functions.createExecution(
functionId: "send-mfa-code",
body: "{\"challengeId\":\"\(challenge.id)\"}",
async: false
)
val execution = functions.createExecution(
functionId = "send-mfa-code",
body = """{"challengeId":"${challenge.id}"}""",
async = false
)
Keep the execution synchronous. Your application then knows that the delivery succeeded before it shows the code input field.
Read the code and deliver it
The function reads the code and sends it.
import { Client, Users } from 'node-appwrite';
export default async ({ req, res, error }) => {
const userId = req.headers['x-appwrite-user-id'];
if (!userId) {
return res.json({ ok: false }, 401);
}
const { challengeId } = JSON.parse(req.bodyRaw || '{}');
const client = new Client()
.setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT)
.setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID)
.setKey(req.headers['x-appwrite-key']);
const users = new Users(client);
// Appwrite returns 401 if the challenge belongs to a different user.
const challenge = await users.getMFAChallenge({ userId, challengeId });
// Read the destination from your own table, never from the request body.
const destination = await lookupWhatsAppNumber(userId);
await sendWhatsAppMessage(destination, `Your code is ${challenge.code}`);
return res.json({ ok: true });
};
use appwrite::services::Users;
use appwrite::Client;
use openruntimes::{Context, Response};
use serde_json::json;
pub fn main(context: Context) -> Response {
let user_id = match context.req.headers.get("x-appwrite-user-id") {
Some(v) if !v.is_empty() => v.clone(),
_ => return context.res.json(&json!({ "ok": false }), Some(401), None),
};
let api_key = context.req.headers.get("x-appwrite-key").cloned().unwrap_or_default();
let body: serde_json::Value = serde_json::from_str(&context.req.body_text()).unwrap_or(json!({}));
let challenge_id = body["challengeId"].as_str().unwrap_or_default().to_string();
let runtime = tokio::runtime::Runtime::new().unwrap();
let result = runtime.block_on(async {
let client = Client::new()
.set_endpoint(std::env::var("APPWRITE_FUNCTION_API_ENDPOINT").unwrap_or_default())
.set_project(std::env::var("APPWRITE_FUNCTION_PROJECT_ID").unwrap_or_default())
.set_key(api_key);
Users::new(&client).get_mfa_challenge(user_id.clone(), challenge_id).await
});
let challenge = match result {
Ok(c) => c,
Err(e) => {
context.error(format!("{:?}", e));
return context.res.json(&json!({ "ok": false }), Some(401), None);
}
};
send_whatsapp_message(&lookup_whatsapp_number(&user_id), &challenge.code);
context.res.json(&json!({ "ok": true }), Some(200), None)
}
from appwrite.client import Client
from appwrite.services.users import Users
from appwrite.models import MfaChallengeSecret
client = Client()
client.set_endpoint('https://<REGION>.cloud.appwrite.io/v1')
client.set_project('<PROJECT_ID>')
client.set_key('<API_KEY>')
users = Users(client)
result: MfaChallengeSecret = users.get_mfa_challenge(
user_id = '<USER_ID>',
challenge_id = '<CHALLENGE_ID>'
)
<?php
use Appwrite\Client;
use Appwrite\Services\Users;
$client = (new Client())
->setEndpoint('https://<REGION>.cloud.appwrite.io/v1')
->setProject('<PROJECT_ID>')
->setKey('<API_KEY>');
$users = new Users($client);
$result = $users->getMFAChallenge(
userId: '<USER_ID>',
challengeId: '<CHALLENGE_ID>'
);
require 'appwrite'
include Appwrite
client = Client.new
.set_endpoint('https://<REGION>.cloud.appwrite.io/v1')
.set_project('<PROJECT_ID>')
.set_key('<API_KEY>')
users = Users.new(client)
result = users.get_mfa_challenge(
user_id: '<USER_ID>',
challenge_id: '<CHALLENGE_ID>'
)
import 'package:dart_appwrite/dart_appwrite.dart';
Users users = Users(client);
MfaChallengeSecret result = await users.getMFAChallenge(
userId: '<USER_ID>',
challengeId: '<CHALLENGE_ID>',
);
using Appwrite;
using Appwrite.Models;
using Appwrite.Services;
Users users = new Users(client);
MfaChallengeSecret result = await users.GetMFAChallenge(
userId: "<USER_ID>",
challengeId: "<CHALLENGE_ID>"
);
import Appwrite
let users = Users(client)
let mfaChallengeSecret = try await users.getMFAChallenge(
userId: "<USER_ID>",
challengeId: "<CHALLENGE_ID>"
)
package main
import (
"github.com/appwrite/sdk-for-go/v6/users"
)
service := users.New(client)
response, error := service.GetMFAChallenge(
"<USER_ID>",
"<CHALLENGE_ID>",
)
Complete the challenge
The user reads the code on their phone and types it.
const session = await account.updateMFAChallenge({
challengeId: challenge.$id,
otp: userInput
});
console.log(session.factors); // ["password", "custom"]
Session session = await account.updateMFAChallenge(
challengeId: challenge.$id,
otp: userInput,
);
let session = try await account.updateMFAChallenge(
challengeId: challenge.id,
otp: userInput
)
val session = account.updateMFAChallenge(
challengeId = challenge.id,
otp = userInput
)
Availability
The custom MFA factor is available in Appwrite Cloud. Update your server and client SDKs to latest.





