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:
- A voice call
- An internal messaging system
- A hardware token service
- A third-party provider that Appwrite does not support directly
Custom token login uses the same pattern for sign-in. This page applies the pattern to the second factor.
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.
How the flow works
- Client. Create a challenge with the
customfactor. Appwrite stores a 6-digit code. - Client. Call your function and pass the challenge ID.
- Function. Read the code with a Server SDK.
- Function. Send the code through your channel.
- Client. Complete the challenge with the code the user typed.
Before you start
You need two things.
- An Appwrite Function, or your own backend.
- The
users.readscope. For an Appwrite Function, set the scope on the function. Appwrite then puts a temporary API key in thex-appwrite-keyheader of each execution, and you do not store a permanent key.
Create the challenge
Call this from your app after the user completes the first factor.
import { Client, Account, AuthenticationFactor } from "appwrite";
const client = new Client()
.setEndpoint('https://<REGION>.cloud.appwrite.io/v1') // Your API Endpoint
.setProject('<PROJECT_ID>'); // Your project ID
const account = new Account(client);
const challenge = await account.createMFAChallenge({
factor: AuthenticationFactor.Custom
});
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
Send the challenge ID to your function with the Client SDK. Use createExecution, and not the function domain.
import { Functions } from "appwrite";
const functions = new Functions(client);
await functions.createExecution({
functionId: '<FUNCTION_ID>',
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 the code with a Server SDK. Give the path parameter the user ID from the x-appwrite-user-id header.
const sdk = require('node-appwrite');
const client = new sdk.Client()
.setEndpoint('https://<REGION>.cloud.appwrite.io/v1') // Your API Endpoint
.setProject('<PROJECT_ID>') // Your project ID
.setKey('<API_KEY>'); // Your secret API key
const users = new sdk.Users(client);
const result = await users.getMFAChallenge({
userId: '<USER_ID>',
challengeId: '<CHALLENGE_ID>'
});
Deliver the 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.
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 });
};
Complete the challenge
The user types the code. Send the code with the challenge ID.
const session = await account.updateMFAChallenge({
challengeId: challenge.$id,
otp: '<OTP>'
});
Security requirements
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.