From a26241ae9b3a14d97a8215f1567bb94244568800 Mon Sep 17 00:00:00 2001 From: Atharva Deosthale Date: Mon, 10 Aug 2026 19:16:54 +0530 Subject: [PATCH 1/2] feat(docs): add custom MFA factor docs and announcement blog Add a Custom MFA factor page under Auth, covering the full flow: create a challenge with the custom factor, read the code with a Server SDK, deliver it from a function, and complete the challenge. Add the announcement and tutorial blog post for the same feature. Link the new page from the MFA page intro and from a Custom tab in the Create challenge step, and add the sidebar entry. The blog cover is not created yet and points at a placeholder path. --- .../blog/post/custom-mfa-factor/+page.markdoc | 375 +++++++++++++++++ src/routes/docs/products/auth/+layout.svelte | 5 + .../products/auth/custom-mfa/+page.markdoc | 386 ++++++++++++++++++ .../docs/products/auth/mfa/+page.markdoc | 15 + 4 files changed, 781 insertions(+) create mode 100644 src/routes/blog/post/custom-mfa-factor/+page.markdoc create mode 100644 src/routes/docs/products/auth/custom-mfa/+page.markdoc diff --git a/src/routes/blog/post/custom-mfa-factor/+page.markdoc b/src/routes/blog/post/custom-mfa-factor/+page.markdoc new file mode 100644 index 00000000000..ad43a8925f9 --- /dev/null +++ b/src/routes/blog/post/custom-mfa-factor/+page.markdoc @@ -0,0 +1,375 @@ +--- +layout: post +title: "Custom MFA factor: send a second factor through any channel" +description: 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. +date: 2026-08-10 +cover: /images/blog/custom-mfa-factor/cover.avif +timeToRead: 8 +author: atharva +category: announcement, authentication +featured: false +faqs: + - question: "What is the custom MFA factor?" + answer: "It is a fifth multi-factor authentication factor in Appwrite Authentication. Appwrite generates a six-digit code and verifies it, but it delivers nothing. Your application reads the code with a Server SDK and sends it through any channel you want." + - question: "Which channels can you use for a second factor?" + answer: "Any channel your application can reach. WhatsApp, a voice call, Telegram, an internal messaging system, a hardware token service, or a third-party provider such as a regional SMS gateway." + - question: "Can a user read their own MFA challenge code?" + answer: "No. The read endpoint accepts an API key or admin mode only. A client session that calls it receives a 401 error. The code also never appears in the create response or in the verification response." + - question: "Does the custom factor force users to complete two steps?" + answer: "Not on its own. Appwrite requires a second factor only when the user has a verified email, a verified phone number, or a verified authenticator app. Pair the custom factor with one built-in factor, or enforce the second step in your own application." + - question: "Do you need to store an API key in your function?" + answer: "No. Give the function the users.read scope. Appwrite then puts a temporary key in the x-appwrite-key header of every execution, and the function uses that key." +--- + +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](/docs/products/auth/custom-token) 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. + +1. In the Appwrite Console, open **Functions**. +2. Create a function with the Node.js or Rust runtime. +3. Open the **Settings** tab of the function. +4. Under **Scopes**, enable `users.read`. +5. Under **Execute access**, add the `users` role. 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. + +1. **Client.** `account.createMFAChallenge({ factor: 'custom' })` +2. **Client.** `functions.createExecution` with the challenge ID +3. **Function.** `users.getMFAChallenge(userId, challengeId)` +4. **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. + +{% multicode %} +```client-web +import { Client, Account, AuthenticationFactor } from "appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') + .setProject(''); + +const account = new Account(client); + +const challenge = await account.createMFAChallenge({ + factor: AuthenticationFactor.Custom +}); +``` + +```client-flutter +import 'package:appwrite/appwrite.dart'; +import 'package:appwrite/enums.dart' as enums; + +MfaChallenge challenge = await account.createMFAChallenge( + factor: enums.AuthenticationFactor.custom, +); +``` + +```client-apple +let challenge = try await account.createMFAChallenge( + factor: .custom +) +``` + +```client-android-kotlin +val challenge = account.createMFAChallenge( + factor = AuthenticationFactor.CUSTOM +) +``` +{% /multicode %} + +The response carries the challenge ID and the expiry time. It carries no code. + +# Call the function + +{% multicode %} +```client-web +import { Functions } from "appwrite"; + +const functions = new Functions(client); + +await functions.createExecution({ + functionId: 'send-mfa-code', + body: JSON.stringify({ challengeId: challenge.$id }), + async: false +}); +``` + +```client-flutter +Execution execution = await functions.createExecution( + functionId: 'send-mfa-code', + body: jsonEncode({'challengeId': challenge.$id}), + xasync: false, +); +``` + +```client-apple +let execution = try await functions.createExecution( + functionId: "send-mfa-code", + body: "{\"challengeId\":\"\(challenge.id)\"}", + async: false +) +``` + +```client-android-kotlin +val execution = functions.createExecution( + functionId = "send-mfa-code", + body = """{"challengeId":"${challenge.id}"}""", + async = false +) +``` +{% /multicode %} + +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. + +{% multicode %} +```server-nodejs +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 }); +}; +``` + +```server-rust +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) +} +``` + +```python +from appwrite.client import Client +from appwrite.services.users import Users +from appwrite.models import MfaChallengeSecret + +client = Client() +client.set_endpoint('https://.cloud.appwrite.io/v1') +client.set_project('') +client.set_key('') + +users = Users(client) + +result: MfaChallengeSecret = users.get_mfa_challenge( + user_id = '', + challenge_id = '' +) +``` + +```php +setEndpoint('https://.cloud.appwrite.io/v1') + ->setProject('') + ->setKey(''); + +$users = new Users($client); + +$result = $users->getMFAChallenge( + userId: '', + challengeId: '' +); +``` + +```ruby +require 'appwrite' + +include Appwrite + +client = Client.new + .set_endpoint('https://.cloud.appwrite.io/v1') + .set_project('') + .set_key('') + +users = Users.new(client) + +result = users.get_mfa_challenge( + user_id: '', + challenge_id: '' +) +``` + +```dart +import 'package:dart_appwrite/dart_appwrite.dart'; + +Users users = Users(client); + +MfaChallengeSecret result = await users.getMFAChallenge( + userId: '', + challengeId: '', +); +``` + +```csharp +using Appwrite; +using Appwrite.Models; +using Appwrite.Services; + +Users users = new Users(client); + +MfaChallengeSecret result = await users.GetMFAChallenge( + userId: "", + challengeId: "" +); +``` + +```swift +import Appwrite + +let users = Users(client) + +let mfaChallengeSecret = try await users.getMFAChallenge( + userId: "", + challengeId: "" +) +``` + +```go +package main + +import ( + "github.com/appwrite/sdk-for-go/v6/users" +) + +service := users.New(client) + +response, error := service.GetMFAChallenge( + "", + "", +) +``` +{% /multicode %} + +# Complete the challenge + +The user reads the code on their phone and types it. + +{% multicode %} +```client-web +const session = await account.updateMFAChallenge({ + challengeId: challenge.$id, + otp: userInput +}); + +console.log(session.factors); // ["password", "custom"] +``` + +```client-flutter +Session session = await account.updateMFAChallenge( + challengeId: challenge.$id, + otp: userInput, +); +``` + +```client-apple +let session = try await account.updateMFAChallenge( + challengeId: challenge.id, + otp: userInput +) +``` + +```client-android-kotlin +val session = account.updateMFAChallenge( + challengeId = challenge.id, + otp = userInput +) +``` +{% /multicode %} + +# Availability + +The custom MFA factor is available in Appwrite Cloud. Update your server and client SDKs to latest. + +# Resources + +- [Custom MFA factor documentation](/docs/products/auth/custom-mfa) +- [Multi-factor authentication documentation](/docs/products/auth/mfa) +- [Custom token login](/docs/products/auth/custom-token) +- [Appwrite Functions documentation](/docs/products/functions) +- [Join the Appwrite Discord community](https://appwrite.io/discord) diff --git a/src/routes/docs/products/auth/+layout.svelte b/src/routes/docs/products/auth/+layout.svelte index e651ad26125..32621d224dd 100644 --- a/src/routes/docs/products/auth/+layout.svelte +++ b/src/routes/docs/products/auth/+layout.svelte @@ -126,6 +126,11 @@ label: 'Multi-factor authentication', href: '/docs/products/auth/mfa' }, + { + label: 'Custom MFA factor', + href: '/docs/products/auth/custom-mfa', + new: isNewUntil('30 November 2026') + }, { label: 'Auth status check', href: '/docs/products/auth/checking-auth-status' diff --git a/src/routes/docs/products/auth/custom-mfa/+page.markdoc b/src/routes/docs/products/auth/custom-mfa/+page.markdoc new file mode 100644 index 00000000000..8f70a1f5a9c --- /dev/null +++ b/src/routes/docs/products/auth/custom-mfa/+page.markdoc @@ -0,0 +1,386 @@ +--- +layout: article +title: Custom MFA factor +description: Deliver a second authentication factor through any channel you want. Use Appwrite Functions or your own backend to send the challenge code. +--- + +Appwrite has three built-in factors for multi-factor authentication: email, phone (SMS), and TOTP. The custom factor removes the channel limit. Appwrite generates and verifies the code. Your application decides how the user receives it. + +Use the custom factor to send the second factor through a channel of your choice. Examples: + +- WhatsApp +- A voice call +- An internal messaging system +- A hardware token service +- A third-party provider that Appwrite does not support directly + +[Custom token login](/docs/products/auth/custom-token) uses the same pattern for sign-in. This page applies the pattern to the second factor. + +{% info title="Appwrite delivers nothing for this factor" %} +A custom challenge sends no email and no SMS. If your application does not deliver the code, the user cannot complete the challenge. +{% /info %} + +# How the flow works {% #how-it-works %} + +1. **Client.** Create a challenge with the `custom` factor. Appwrite stores a 6-digit code. +2. **Client.** Call your function and pass the challenge ID. +3. **Function.** Read the code with a Server SDK. +4. **Function.** Send the code through your channel. +5. **Client.** Complete the challenge with the code the user typed. + +# Before you start {% #before-you-start %} + +You need two things. + +- An Appwrite Function, or your own backend. +- The `users.read` scope. For an Appwrite Function, set the scope on the function. Appwrite then puts a temporary API key in the `x-appwrite-key` header of each execution, and you do not store a permanent key. + +# Create the challenge {% #create-challenge %} + +Call this from your app after the user completes the first factor. + +{% multicode %} +```client-web +import { Client, Account, AuthenticationFactor } from "appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const account = new Account(client); + +const challenge = await account.createMFAChallenge({ + factor: AuthenticationFactor.Custom +}); +``` + +```client-flutter +import 'package:appwrite/appwrite.dart'; +import 'package:appwrite/enums.dart' as enums; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Account account = Account(client); + +MfaChallenge challenge = await account.createMFAChallenge( + factor: enums.AuthenticationFactor.custom, +); +``` + +```client-apple +import Appwrite +import AppwriteEnums + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let account = Account(client) + +let challenge = try await account.createMFAChallenge( + factor: .custom +) +``` + +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.enums.AuthenticationFactor +import io.appwrite.services.Account + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val account = Account(client) + +val challenge = account.createMFAChallenge( + factor = AuthenticationFactor.CUSTOM +) +``` +{% /multicode %} + +The response holds the challenge ID and the expiry time. It never holds the code. Keep the challenge ID for the last step. + +# Call your function {% #call-function %} + +Send the challenge ID to your function with the Client SDK. Use `createExecution`, and not the function domain. + +```client-web +import { Functions } from "appwrite"; + +const functions = new Functions(client); + +await functions.createExecution({ + functionId: '', + body: JSON.stringify({ challengeId: challenge.$id }), + async: false +}); +``` + +`createExecution` puts the ID of the signed-in user in the `x-appwrite-user-id` header of the execution. A direct call to the function domain leaves that header empty, and your function cannot then know who the caller is. + +# Read the code {% #read-code %} + +Read the code with a Server SDK. Give the path parameter the user ID from the `x-appwrite-user-id` header. + +{% multicode %} +```server-nodejs +const sdk = require('node-appwrite'); + +const client = new sdk.Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject('') // Your project ID + .setKey(''); // Your secret API key + +const users = new sdk.Users(client); + +const result = await users.getMFAChallenge({ + userId: '', + challengeId: '' +}); +``` + +```python +from appwrite.client import Client +from appwrite.services.users import Users +from appwrite.models import MfaChallengeSecret + +client = Client() +client.set_endpoint('https://.cloud.appwrite.io/v1') # Your API Endpoint +client.set_project('') # Your project ID +client.set_key('') # Your secret API key + +users = Users(client) + +result: MfaChallengeSecret = users.get_mfa_challenge( + user_id = '', + challenge_id = '' +) +``` + +```php +setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + ->setProject('') // Your project ID + ->setKey(''); // Your secret API key + +$users = new Users($client); + +$result = $users->getMFAChallenge( + userId: '', + challengeId: '' +); +``` + +```ruby +require 'appwrite' + +include Appwrite + +client = Client.new + .set_endpoint('https://.cloud.appwrite.io/v1') # Your API Endpoint + .set_project('') # Your project ID + .set_key('') # Your secret API key + +users = Users.new(client) + +result = users.get_mfa_challenge( + user_id: '', + challenge_id: '' +) +``` + +```server-rust +use appwrite::Client; +use appwrite::services::Users; + +let client = Client::new(); +client.set_endpoint("https://.cloud.appwrite.io/v1"); // Your API Endpoint +client.set_project(""); // Your project ID +client.set_key(""); // Your secret API key + +let users = Users::new(&client); + +let result = users.get_mfa_challenge( + "", + "" +).await?; +``` + +```dart +import 'package:dart_appwrite/dart_appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject('') // Your project ID + .setKey(''); // Your secret API key + +Users users = Users(client); + +MfaChallengeSecret result = await users.getMFAChallenge( + userId: '', + challengeId: '', +); +``` + +```csharp +using Appwrite; +using Appwrite.Models; +using Appwrite.Services; + +Client client = new Client() + .SetEndPoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .SetProject("") // Your project ID + .SetKey(""); // Your secret API key + +Users users = new Users(client); + +MfaChallengeSecret result = await users.GetMFAChallenge( + userId: "", + challengeId: "" +); +``` + +```swift +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + .setKey("") // Your secret API key + +let users = Users(client) + +let mfaChallengeSecret = try await users.getMFAChallenge( + userId: "", + challengeId: "" +) +``` +{% /multicode %} + +# Deliver the code {% #deliver-code %} + +Put the last two steps together in a function. Read the destination address from your own data. Never read it from the request body. + +{% multicode %} +```server-nodejs +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); + + const challenge = await users.getMFAChallenge({ userId, challengeId }); + + // Read the destination from your own table, and not from the request. + const destination = await lookupWhatsAppNumber(userId); + + await sendWhatsAppMessage(destination, `Your code is ${challenge.code}`); + + return res.json({ ok: true }); +}; +``` + +```server-rust +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 challenge = 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 challenge { + Ok(c) => c, + Err(e) => { + context.error(format!("{:?}", e)); + return context.res.json(&json!({ "ok": false }), Some(401), None); + } + }; + + // Read the destination from your own table, and not from the request. + send_whatsapp_message(&lookup_whatsapp_number(&user_id), &challenge.code); + + context.res.json(&json!({ "ok": true }), Some(200), None) +} +``` +{% /multicode %} + +# Complete the challenge {% #complete-challenge %} + +The user types the code. Send the code with the challenge ID. + +{% multicode %} +```client-web +const session = await account.updateMFAChallenge({ + challengeId: challenge.$id, + otp: '' +}); +``` + +```client-flutter +Session session = await account.updateMFAChallenge( + challengeId: challenge.$id, + otp: '', +); +``` + +```client-apple +let session = try await account.updateMFAChallenge( + challengeId: challenge.id, + otp: "" +) +``` + +```client-android-kotlin +val session = account.updateMFAChallenge( + challengeId = challenge.id, + otp = "" +) +``` +{% /multicode %} + +# Security requirements {% #security %} + +Read this section before you go to production. + +- **Select the destination on the server.** Read the telephone number, the chat ID, or the address from your own table. If you take the destination from the request body, an attacker who knows the password can send the code to their own device. +- **Use the user ID from the header.** Use `x-appwrite-user-id`. Never use a user ID that the client sends. Appwrite then rejects a challenge that belongs to a different user. Your function needs no ownership check of its own. +- **Never log the code.** Keep the code out of your logs and out of your responses to the client. diff --git a/src/routes/docs/products/auth/mfa/+page.markdoc b/src/routes/docs/products/auth/mfa/+page.markdoc index db3ef30b015..00ac72ba604 100644 --- a/src/routes/docs/products/auth/mfa/+page.markdoc +++ b/src/routes/docs/products/auth/mfa/+page.markdoc @@ -15,6 +15,8 @@ If you are looking for MFA on your Appwrite Console account, please refer to the Appwrite currently allows two factors of authentication. More factors of authentication will be available soon. +This page covers the built-in factors: email, phone (SMS), TOTP, and recovery codes. To send the second factor through your own channel, see [Custom MFA factor](/docs/products/auth/custom-mfa). + Here are the steps to implement MFA in your application. {% section #display-recover-code step=1 title="Display recovery codes" %} @@ -783,6 +785,7 @@ The returned object will be formatted like this. } ``` + {% multicode %} ```client-web const factors = await account.listMfaFactors(); @@ -955,6 +958,18 @@ val challengeId = response.id ``` {% /multicode %} {% /tabsitem %} + +{% tabsitem #custom title="Custom" %} +Appwrite generates a code and delivers nothing. Your application sends the code through any channel, such as WhatsApp, a voice call, or an internal system. + +```client-web +const challenge = await account.createMFAChallenge({ + factor: AuthenticationFactor.Custom +}); +``` + +The steps that read and deliver the code need a Server SDK. See [Custom MFA factor](/docs/products/auth/custom-mfa) for the full flow and the security requirements. +{% /tabsitem %} {% /tabs %} {% /section %} From f1afc10a728938124e81a8cd29b5e8665f6c8faa Mon Sep 17 00:00:00 2001 From: Atharva Deosthale Date: Fri, 14 Aug 2026 21:58:04 +0530 Subject: [PATCH 2/2] docs(blog): add changelog entry and cover for the custom MFA factor Move the announcement to 2026-08-14 and add the matching changelog entry. --- .optimize-cache.json | 1 + .../blog/post/custom-mfa-factor/+page.markdoc | 2 +- .../changelog/(entries)/2026-08-14.markdoc | 14 ++++++++++++++ static/images/blog/custom-mfa-factor/cover.avif | Bin 0 -> 8037 bytes 4 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 src/routes/changelog/(entries)/2026-08-14.markdoc create mode 100644 static/images/blog/custom-mfa-factor/cover.avif diff --git a/.optimize-cache.json b/.optimize-cache.json index 5df8091b36b..0e9c79dd68e 100644 --- a/.optimize-cache.json +++ b/.optimize-cache.json @@ -538,6 +538,7 @@ "static/images/blog/custom-domains-with-sites/ns-records-namecheap.png": "e32379e593ce425ca16638474f94cb1ebfd5e488940bdc9abbf8565244206d72", "static/images/blog/custom-domains-with-sites/organization-overview.png": "992a870d8037ca191f6933066ceb4458aaa697cff92b1375f059cb20f1116e2c", "static/images/blog/custom-domains-with-sites/retry-dns-checks.png": "c7fa3092906d8808e76ca71798a4cbee8052adf9d745f31459f98cd14b160dc3", + "static/images/blog/custom-mfa-factor/cover.png": "931f6899580b9846a09ec78f0f5dda3adec314f800759310b3b5c50bdfee55c6", "static/images/blog/customer-story-radar/cover.png": "17f4c901da2f03ba25a7e9b3d3d43978b41ebece2abbdc01d55da0bb6ad26fcd", "static/images/blog/customer-story-radar/product-hunt.png": "21985a959b483ea0ca574266a387230805fa5ff84ace9bcc9357a0c60deba97a", "static/images/blog/customer-story-radar/radar.png": "ffa66f12e5e421699f3205dc35ee943c517f562bf3ff32d2d69999f2d9e37b7c", diff --git a/src/routes/blog/post/custom-mfa-factor/+page.markdoc b/src/routes/blog/post/custom-mfa-factor/+page.markdoc index ad43a8925f9..293360b3e0a 100644 --- a/src/routes/blog/post/custom-mfa-factor/+page.markdoc +++ b/src/routes/blog/post/custom-mfa-factor/+page.markdoc @@ -2,7 +2,7 @@ layout: post title: "Custom MFA factor: send a second factor through any channel" description: 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. -date: 2026-08-10 +date: 2026-08-14 cover: /images/blog/custom-mfa-factor/cover.avif timeToRead: 8 author: atharva diff --git a/src/routes/changelog/(entries)/2026-08-14.markdoc b/src/routes/changelog/(entries)/2026-08-14.markdoc new file mode 100644 index 00000000000..205e8663a59 --- /dev/null +++ b/src/routes/changelog/(entries)/2026-08-14.markdoc @@ -0,0 +1,14 @@ +--- +layout: changelog +title: 'Send your MFA code through any channel with the custom factor' +date: 2026-08-14 +cover: /images/blog/custom-mfa-factor/cover.avif +--- + +The new **custom** factor for [multi-factor authentication](/docs/products/auth/mfa) removes the channel limit. Appwrite generates and verifies a 6-digit code, and your application decides how the user receives it: WhatsApp, a voice call, an internal messaging system, or any provider that Appwrite does not support directly. + +Create a challenge with the `custom` factor, then read the code with a Server SDK, or from an Appwrite Function that holds the `users.read` scope. Appwrite delivers nothing for this factor, so your application sends the code through your own channel. The user then completes the challenge in the same way as every other factor. + +{% arrow_link href="/blog/post/custom-mfa-factor" %} +Read the announcement +{% /arrow_link %} diff --git a/static/images/blog/custom-mfa-factor/cover.avif b/static/images/blog/custom-mfa-factor/cover.avif new file mode 100644 index 0000000000000000000000000000000000000000..dfafe97aa23285640de1d28bff404c1a4793dc38 GIT binary patch literal 8037 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 DY$+<9 literal 0 HcmV?d00001