Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/routes/docs/products/auth/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@
href: '/docs/products/auth/custom-mfa',
new: isNewUntil('30 November 2026')
},
{
label: 'Custom MFA channels',
href: '/docs/products/auth/custom-mfa-channels',
new: isNewUntil('30 November 2026')
},
{
label: 'Auth status check',
href: '/docs/products/auth/checking-auth-status'
Expand Down
368 changes: 368 additions & 0 deletions src/routes/docs/products/auth/custom-mfa-channels/+page.markdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,368 @@
---
layout: article
title: Custom MFA channels
description: Send the custom MFA code through Telegram or WhatsApp. Store the destination on the server, then deliver the code from an Appwrite Function.
---

The [custom MFA factor](/docs/products/auth/custom-mfa) generates the code. It sends nothing. This page shows the delivery step for two channels: Telegram and WhatsApp.

Each channel needs two parts.

- **A destination.** The chat ID or the telephone number of the user.
- **A delivery call.** One HTTP request to the provider.

The destination controls the security of the whole factor. Your function must find the destination from the user ID in the `x-appwrite-user-id` header. If your function reads a destination from the request body, an attacker who knows the password can send the code to their own device.

# Before you start {% #before-you-start %}

- Read [Custom MFA factor](/docs/products/auth/custom-mfa) first. This page continues from the **Deliver the code** step.
- Create an Appwrite Function with the `users.read` scope.
- Keep each provider token in a function variable. Never put a provider token in your client.

# Set up the channel {% #set-up-channel %}

{% tabs %}
{% tabsitem #telegram title="Telegram" %}
A Telegram bot is free, and it needs no approval.

1. Open [@BotFather](https://t.me/BotFather) in Telegram.
2. Send `/newbot`. Give the bot a name and a username.
3. Copy the token that BotFather returns.

Add two variables to your function.

| Variable | Value |
| --- | --- |
| `TELEGRAM_BOT_TOKEN` | The token from BotFather |
| `TELEGRAM_BOT_USERNAME` | The username of the bot, without the `@` |
{% /tabsitem %}

{% tabsitem #whatsapp title="WhatsApp" %}
WhatsApp needs a Meta app and an approved template. Meta permits no free text in a message that starts a conversation, so an authentication template is necessary.

1. Create a Meta app and add the WhatsApp product to it.
2. Copy the phone number ID and a permanent access token.
3. Create an authentication template with an OTP button.

```bash
curl -X POST "https://graph.facebook.com/v25.0/<WHATSAPP_BUSINESS_ACCOUNT_ID>/message_templates" \
-H "Authorization: Bearer <WHATSAPP_ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"name": "mfa_code",
"language": "en_US",
"category": "AUTHENTICATION",
"components": [
{ "type": "BODY", "add_security_recommendation": true },
{ "type": "FOOTER", "code_expiration_minutes": 60 },
{ "type": "BUTTONS", "buttons": [ { "type": "OTP", "otp_type": "COPY_CODE" } ] }
]
}'
```

An authentication template holds no text of your own. Meta supplies the wording, and the body has one variable. That variable is the code.

Add three variables to your function.

| Variable | Value |
| --- | --- |
| `WHATSAPP_PHONE_NUMBER_ID` | The phone number ID of the sender |
| `WHATSAPP_ACCESS_TOKEN` | A permanent access token |
| `WHATSAPP_TEMPLATE_NAME` | The name of the template, for example `mfa_code` |

{% info title="Meta reviews each template" %}
A template that is not `APPROVED` sends nothing. Review usually takes minutes, but Meta gives no guarantee. Test the function against the Meta test number before you go to production.
{% /info %}
{% /tabsitem %}
{% /tabs %}

# Store the destination {% #store-destination %}

{% tabs %}
{% tabsitem #telegram title="Telegram" %}
Appwrite keeps no Telegram chat ID. You must store it, and you must prove that the chat belongs to the user.

Never accept a chat ID from the client. Use a one-time link instead. Telegram passes the value after `?start=` to your bot, so the link binds the Appwrite user to the chat.

Create a table `mfa_telegram` in a database `main` with these columns.

| Column | Type | Description |
| --- | --- | --- |
| `chatId` | Integer | The Telegram chat of the user. Empty until enrollment completes. |
| `linkToken` | String, 64 | The one-time value in the link. Empty after enrollment completes. |
Comment on lines +89 to +92

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Column optionality is unspecified

The schema describes chatId as empty before enrollment and linkToken as empty afterward, but it does not tell readers to make those columns optional or provide defaults. Configuring either column as required makes the corresponding initial upsert or enrollment-completion update fail validation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/routes/docs/products/auth/custom-mfa-channels/+page.markdoc
Line: 89-92

Comment:
**Column optionality is unspecified**

The schema describes `chatId` as empty before enrollment and `linkToken` as empty afterward, but it does not tell readers to make those columns optional or provide defaults. Configuring either column as required makes the corresponding initial upsert or enrollment-completion update fail validation.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex


Add an index on `linkToken`. Give the table no row permissions. Only a server key reads it.

**Step 1. Return the link.** The signed-in user calls this function with `createExecution`. The row ID is the user ID, so each user holds one row.

```server-nodejs
import { Client, TablesDB } from 'node-appwrite';
import { randomBytes } from 'node:crypto';

export default async ({ req, res }) => {
const userId = req.headers['x-appwrite-user-id'];

if (!userId) {
return res.json({ ok: false }, 401);
}

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 tables = new TablesDB(client);
// Telegram permits 64 characters after `?start=`.
const linkToken = randomBytes(24).toString('hex');

await tables.upsertRow({
databaseId: 'main',
tableId: 'mfa_telegram',
rowId: userId,
data: { linkToken }
});

return res.json({
url: `https://t.me/${process.env.TELEGRAM_BOT_USERNAME}?start=${linkToken}`
});
};
```

This function needs the `rows.write` scope.

**Step 2. Receive the chat ID.** Telegram calls a second function when the user presses **Start**. Set the execute permission of that function to **Any**, and give it the `rows.read` and `rows.write` scopes.

Telegram is not a signed-in client, so `x-appwrite-user-id` is empty in this function. The `x-appwrite-key` header is not empty. A call to the function domain also carries a temporary key with the scopes of the function.

```server-nodejs
import { Client, TablesDB, Query } from 'node-appwrite';

export default async ({ req, res }) => {
if (req.headers['x-telegram-bot-api-secret-token'] !== process.env.TELEGRAM_WEBHOOK_SECRET) {
return res.json({ ok: false }, 401);
}

const update = JSON.parse(req.bodyRaw || '{}');
const text = update.message?.text ?? '';

if (!text.startsWith('/start ')) {
return res.json({ ok: true });
}

const linkToken = text.slice('/start '.length).trim();

// An empty token matches every row that completed enrollment.
if (!linkToken) {
return res.json({ ok: true });
}

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 tables = new TablesDB(client);

const found = await tables.listRows({
databaseId: 'main',
tableId: 'mfa_telegram',
queries: [Query.equal('linkToken', linkToken), Query.limit(1)]
});

if (found.rows.length !== 1) {
return res.json({ ok: true });
}

await tables.updateRow({
databaseId: 'main',
tableId: 'mfa_telegram',
rowId: found.rows[0].$id,
data: { chatId: update.message.chat.id, linkToken: '' }
});

return res.json({ ok: true });
Comment thread
greptile-apps[bot] marked this conversation as resolved.
};
```

The function clears `linkToken`, so a second use of the same link matches no row.

**Step 3. Point Telegram at the function.** Use the domain of the second function. Choose your own value for the secret, and put the same value in the `TELEGRAM_WEBHOOK_SECRET` variable. Telegram accepts 1 to 256 characters, and only the characters `A-Z`, `a-z`, `0-9`, `_`, and `-`.

```bash
curl -X POST "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \
-H "Content-Type: application/json" \
-d '{
"url": "https://<FUNCTION_DOMAIN>",
"secret_token": "<TELEGRAM_WEBHOOK_SECRET>"
}'
```

Telegram sends the secret in the `X-Telegram-Bot-Api-Secret-Token` header of each call. The function rejects a call that has no correct secret.
{% /tabsitem %}

{% tabsitem #whatsapp title="WhatsApp" %}
WhatsApp needs no table. Appwrite already holds a telephone number for each user, and it also holds the verification state of that number.

Use `phone` from the user record, and send only when `phoneVerification` is `true`. An unverified number is a number that nobody proved, and the code must not go there.

To set the number and verify it, see [Phone (SMS) login](/docs/products/auth/phone-sms) and [User verification](/docs/products/auth/verify-user).
{% /tabsitem %}
{% /tabs %}

# Deliver the code {% #deliver-code %}

This is the function that the client calls after it creates the challenge. It reads the code, finds the destination, and sends one message.

{% tabs %}
{% tabsitem #telegram title="Telegram" %}
```server-nodejs
import { Client, Users, TablesDB } 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 tables = new TablesDB(client);
const users = new Users(client);

let row;

try {
row = await tables.getRow({
databaseId: 'main',
tableId: 'mfa_telegram',
rowId: userId
});
} catch (err) {
// The user has no row until enrollment starts.
if (err.code === 404) {
return res.json({ ok: false, reason: 'not-enrolled' }, 400);
}

throw err;
}

if (!row.chatId) {
return res.json({ ok: false, reason: 'not-enrolled' }, 400);
}

const challenge = await users.getMFAChallenge({ userId, challengeId });

const response = await fetch(
`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: row.chatId,
text: `Your verification code is ${challenge.code}. The code expires in 60 minutes.`
})
}
);

if (!response.ok) {
error(`Telegram refused the message with status ${response.status}.`);
return res.json({ ok: false }, 502);
}

return res.json({ ok: true });
};
```

This function needs the `users.read` and `rows.read` scopes.
{% /tabsitem %}

{% tabsitem #whatsapp title="WhatsApp" %}
```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 user = await users.get({ userId });

if (!user.phoneVerification) {
return res.json({ ok: false, reason: 'phone-not-verified' }, 400);
}

const challenge = await users.getMFAChallenge({ userId, challengeId });

const response = await fetch(
`https://graph.facebook.com/v25.0/${process.env.WHATSAPP_PHONE_NUMBER_ID}/messages`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.WHATSAPP_ACCESS_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: user.phone,
type: 'template',
template: {
name: process.env.WHATSAPP_TEMPLATE_NAME,
language: { code: 'en_US' },
components: [
{
type: 'body',
parameters: [{ type: 'text', text: challenge.code }]
},
{
type: 'button',
sub_type: 'url',
index: '0',
parameters: [{ type: 'text', text: challenge.code }]
}
]
}
})
}
);

if (!response.ok) {
error(`Meta refused the message with status ${response.status}.`);
return res.json({ ok: false }, 502);
}

return res.json({ ok: true });
};
```

The button component carries the code a second time. It fills the copy button of the authentication template. Set `sub_type` to `url` for both OTP button types.

This function needs the `users.read` scope.
{% /tabsitem %}
{% /tabs %}

# Security requirements {% #security %}

The rules on [Custom MFA factor](/docs/products/auth/custom-mfa) apply here. These rules apply to the channel.

- **Bind the Telegram chat with a one-time link.** A chat ID from the client proves nothing. The link proves that the same person holds the Appwrite session and the Telegram chat.
- **Check the Telegram webhook secret.** The function domain is public. Without the secret check, any caller can write a chat ID into your table.
- **Send WhatsApp messages only to a verified number.** Read `phoneVerification` before each send. An unverified number can belong to somebody else.
- **Keep provider tokens in function variables.** A bot token or an access token in your client gives full control of the channel to any reader.
- **Never log the code.** Keep the code out of your function logs and out of your response to the client.
Loading