# Двойная авторизация: Appwrite JWT → Supabase JWT через Edge Function

> Data residency + глобальное приложение: JWT-цепочка Appwrite → Supabase, AuthBridge на 6 систем, Completer-lock и SyncRecovery. Архитектура dual-auth для Flutter.
> Author: Roman Belov · Published: 2026-02-22 · Source: https://futurecraft.pro/ru/blog/dual-auth-appwrite-supabase/

Один auth-провайдер в приложении — стандарт. Два — необходимость, когда персональные данные пользователей должны оставаться на территории одной страны, а приложение работает глобально.

В JourneyBay Appwrite отвечает за логин и регистрацию на региональном сервере. Supabase — за данные, real-time подписки и серверную логику в глобальном облаке. Между ними — Edge Function, которая конвертирует один JWT в другой. На клиенте — AuthBridge, синглтон, который одним событием синхронизирует шесть подсистем.

Расскажу, как это устроено, зачем пришлось строить и чего это стоит.

## Зачем два провайдера вместо одного

Причина — data residency. В России (и ряде других стран) закон требует хранить персональные данные пользователей на серверах внутри страны. Учётные записи, пароли, email, данные сессий — всё это должно лежать на локальном сервере. При этом приложение работает глобально: маршруты, рекомендации, AI-генерация — эти данные не привязаны к конкретной юрисдикции.

Одна платформа не закрывает обе задачи. Нужны две: региональная для персональных данных и глобальная для всего остального.

**Appwrite** развёрнут на собственном VPS в нужном регионе. Self-hosted, полный контроль над данными. Он хранит учётные записи, управляет сессиями, обрабатывает OAuth-потоки. Персональные данные не покидают территорию страны.

**Supabase** работает в глобальном облаке. PostgreSQL со всеми возможностями: join'ы, оконные функции, полнотекстовый поиск, JSONB. Плюс Row-Level Security (RLS), которая проверяет права доступа на уровне базы данных — не в коде приложения, а в самом PostgreSQL. И Edge Functions на Deno для серверной логики. Здесь лежат маршруты, POI, чеклисты, AI-генерации — всё, что не является персональными данными.

Можно ли использовать один провайдер для всего? В теории — да. На практике: либо self-hosted база теряет облачные преимущества (managed hosting, CDN, Edge Functions), либо глобальное облако нарушает локальное законодательство. Мост между двумя системами — компромисс, который сохраняет плюсы обеих платформ.

Принцип: персональные данные — на региональном сервере, приложенческие данные — в глобальном облаке, а между ними надёжная связка.

## JWT-цепочка: от логина до запроса в базу

Весь dual-auth сводится к одной операции: конвертации токена. Пользователь авторизуется через Appwrite, получает Appwrite JWT. Этот токен обменивается на Supabase JWT через серверную функцию. Supabase JWT используется для всех запросов к данным.

Вот как выглядит поток:

```
Пользователь
    │
    │  email + password
    ▼
┌──────────┐
│ Appwrite │ ── создаёт сессию, возвращает Appwrite JWT (TTL 15 мин)
└──────────┘
    │
    │  Appwrite JWT
    ▼
┌──────────────────┐
│  Edge Function   │ ── проверяет Appwrite JWT → находит/создаёт пользователя
│  (Token Bridge)  │    в связующей таблице → подписывает Supabase JWT (TTL 1 час)
└──────────────────┘
    │
    │  Supabase JWT
    ▼
┌──────────────┐
│   Supabase   │ ── RLS проверяет auth.uid() из JWT → запрос к данным
│  (PostgREST) │
└──────────────┘
```

Два токена, два TTL, одна серверная функция между ними. Token Bridge принимает короткоживущий Appwrite-токен и возвращает долгоживущий Supabase-токен.

### Почему именно Edge Function

Token Bridge работает как серверная функция на стороне Supabase. Почему не в клиенте?

Потому что подпись JWT требует серверного секрета. Supabase JWT подписывается ключом, который знает только сервер. Клиент физически не может создать валидный Supabase-токен — и не должен. Token Bridge — единственная точка, где Appwrite-аутентификация превращается в Supabase-авторизацию. Скомпрометировать один токен без доступа к серверному секрету недостаточно для доступа к данным другой системы.

### Жизненный цикл токенов

Два токена — два TTL:

| Токен | TTL | Обновление |
|-------|-----|------------|
| Appwrite JWT | 15 минут | Appwrite SDK автоматически через сессию |
| Supabase JWT | 1 час | Клиент обновляет за 5 минут до истечения |

Клиент отслеживает время жизни Supabase-токена. За пять минут до истечения запрашивает новый Appwrite JWT, отправляет его в Token Bridge и получает свежий Supabase JWT. Пользователь этого не замечает.

Что происходит на холодном старте: приложение открывается → Appwrite SDK восстанавливает сессию → запрашивается Appwrite JWT → отправляется в Token Bridge → Supabase JWT получен → все запросы к данным начинают работать. Весь процесс занимает 1–2 секунды. Если Token Bridge отвечает медленно (cold start Deno-изолята) — клиент показывает экран загрузки.

### Связка пользователей

Token Bridge при первом обращении нового пользователя создаёт запись в связующей таблице. Эта таблица маппит идентификатор пользователя в Appwrite на идентификатор в Supabase. Все RLS-политики в Supabase работают с единым внутренним ID — одинаковым в обеих системах.

При первом логине Token Bridge дополнительно активирует trial-период через серверную SQL-функцию. Пользователь получает доступ к платным функциям без явной регистрации в платёжной системе.

## AuthBridge: один listener на шесть систем

Когда состояние авторизации меняется — пользователь залогинился, вышел, токен обновился — это должно отразиться не только в UI. Шесть подсистем должны узнать об этом изменении и среагировать.

AuthBridge — синглтон в DI-контейнере, который подписывается на изменения auth-состояния через Riverpod-провайдер и рассылает обновления всем зависимым системам.

```
authProvider (Riverpod)
    │
    │  state change
    ▼
┌──────────────┐
│  AuthBridge  │──► CustomAuthManager (legacy FlutterFlow, хранит токен)
│  (singleton) │──► UserStateProvider  (глобальный user state, userId, isLoggedIn)
│              │──► SupabaseClient     (инициализация JWT, HTTP-клиент)
│              │──► Sentry             (привязка user context для ошибок)
│              │──► PostHog            (identify для аналитики)
│              │──► SyncRecovery       (reset при успехе / enqueue при провале)
└──────────────┘
```

При логине AuthBridge выполняет шесть операций последовательно:

1. **CustomAuthManager** получает токен и идентификатор — это legacy-интеграция с FlutterFlow, которая всё ещё используется частью кодовой базы.
2. **UserStateProvider** обновляет глобальное состояние: userId, isLoggedIn, данные профиля. Виджеты, подписанные на этот провайдер, перерисовываются.
3. **SupabaseClientService** запускает обмен токенов через Token Bridge. Это самый долгий шаг — сетевой запрос к Edge Function.
4. **Sentry** получает userId — теперь все ошибки привязаны к конкретному пользователю.
5. **PostHog** вызывает identify — аналитические события привязываются к пользователю.
6. **SyncRecoveryService** сбрасывается при успехе. Если обмен токенов на шаге 3 провалился — SyncRecovery ставит восстановление в очередь.

При логауте — обратный процесс. Каждая система очищает своё состояние. Sentry и PostHog сбрасывают user context. SupabaseClient аннулирует токен.

Зачем синглтон, а не отдельные подписки? Потому что порядок важен. Нельзя отправлять аналитику до инициализации Supabase. Нельзя показывать UI залогиненного пользователя до установки UserState. AuthBridge гарантирует последовательность.

## Completer-lock: три точки дедупликации

В мобильном приложении одно и то же действие может быть вызвано несколькими источниками одновременно. Приложение открывается — `initState` и lifecycle-listener одновременно проверяют авторизацию. Два параллельных запроса получают 401 — оба пытаются обновить токен.

Без защиты это приведёт к дублированию сетевых запросов, race condition'ам и непредсказуемому состоянию.

Решение — паттерн Completer-lock. Работает так: первый вызов создаёт Completer и начинает операцию. Все последующие вызовы обнаруживают, что Completer уже существует, и ждут его результат. Когда операция завершается — все ожидающие получают один и тот же результат.

В auth-системе JourneyBay этот паттерн применяется в трёх точках:

### 1. Проверка авторизации при запуске

Когда приложение открывается, проверка текущей сессии может быть вызвана из нескольких мест: инициализация виджета, lifecycle callback, deeplink handler. Completer гарантирует, что сетевой запрос к Appwrite (`getAccount`) выполнится только один раз. Остальные вызовы дождутся результата.

### 2. Обмен токенов через Token Bridge

SupabaseClientService использует отдельный Completer для вызова Token Bridge. Если AuthBridge инициирует обмен токенов, а параллельно приходит API-запрос, который тоже обнаруживает протухший токен — второй вызов не пойдёт в Token Bridge, а дождётся результата первого.

Важная деталь: при логауте текущий Completer принудительно завершается с ошибкой. Это предотвращает ситуацию, когда запрос к Token Bridge завис в сети, пользователь нажал "Выйти", а через 30 секунд пришёл ответ и перезаписал очищенное состояние.

### 3. Обработка 401 в рантайме

Когда API-запрос возвращает 401, AuthRetryHandler пытается обновить токен и повторить запрос. Если пять параллельных запросов одновременно получили 401 — только один вызовет обновление токена. Остальные четыре дождутся результата и повторят свои запросы с новым токеном.

Если повторный запрос снова вернул 401 — это permanent failure. AuthRetryHandler вызывает callback, который через AuthBridge инициирует полный логаут. Пользователь видит экран авторизации.

Три Completer'а, три уровня защиты. В тестировании ни один обмен токенов не продублировался.

## SyncRecovery: что делать, когда всё сломалось

Обмен токенов — сетевая операция. Сеть ненадёжна. Token Bridge может быть недоступен из-за cold start Deno-изолята, ошибки на сервере или отсутствия интернета у пользователя.

SyncRecoveryService — state machine, которая обрабатывает провал синхронизации с exponential backoff.

```
                    успешный логин
                         │
                         ▼
                    ┌─────────┐
          reset()──►│  idle   │◄──────────── reset() (успешная синхронизация)
                    └─────────┘
                         │
                         │ провал обмена токенов
                         ▼
                    ┌──────────────┐
                    │ pendingRetry │──── таймер с exponential backoff
                    └──────────────┘
                         │
                         │ таймер сработал
                         ▼
                    ┌──────────┐
                    │ syncing  │──── повторная попытка обмена токенов
                    └──────────┘
                       │     │
                  успех│     │провал
                       │     │
                       ▼     ▼
                    idle   попытка < 5?
                              │     │
                           да │     │ нет
                              ▼     ▼
                        pendingRetry  ┌────────┐
                                      │ failed │
                                      └────────┘
```

Задержки между попытками растут: 5 секунд, 15, 30, 60, 120. Пять попыток — после чего состояние переходит в `failed` и пользователь видит баннер "Не удалось синхронизировать. Попробуйте войти заново."

SyncRecovery транслирует своё состояние через StreamProvider. UI подписывается и показывает:
- `syncing` — ненавязчивый индикатор "Синхронизация..."
- `pendingRetry` — "Повторная попытка через N сек"
- `failed` — баннер с кнопкой "Войти заново"

Отдельная логика для `checkPendingSync` при возвращении приложения из фона: если синхронизация была в состоянии `pendingRetry` — сервис сбрасывает счётчик попыток до предпоследнего значения и немедленно делает одну финальную попытку. Смысл: пользователь вернулся в приложение, вероятно сеть восстановилась — стоит попробовать ещё раз.

## WidgetRef в DI-синглтоне: нестандартный паттерн

AuthBridge — синглтон в get_it (DI-контейнер). Riverpod-провайдеры живут в widget tree. Это два разных мира: DI-контейнер создаётся при запуске приложения, а WidgetRef появляется только после первого `build()`.

Проблема: AuthBridge должен слушать `authProvider` (Riverpod), но у него нет WidgetRef.

Решение: отложенная инъекция. AuthBridge создаётся get_it при старте приложения с минимальными зависимостями (только CustomAuthManager). WidgetRef передаётся позже, через метод `startSync(ref)`, который вызывается из `initState()` корневого виджета.

```
App запускается
    │
    ▼
get_it создаёт AuthBridge (без WidgetRef)
    │
    ▼
MyApp.build() → ConsumerStatefulWidget
    │
    ▼
MyApp.initState()
    │
    │  ref (WidgetRef)
    ▼
authBridge.startSync(ref)
    │
    ▼
AuthBridge подписывается на authProvider через ref.listenManual()
```

Почему это не антипаттерн? Потому что альтернативы хуже:

- **Сделать AuthBridge провайдером Riverpod** — тогда он теряет доступ к get_it-зависимостям. Половина приложения на get_it (legacy FlutterFlow), половина на Riverpod. AuthBridge — мост между ними.
- **Передавать WidgetRef в каждый метод** — AuthBridge вызывается из разных мест (retry handler, lifecycle callback). Протаскивать ref через все эти точки — хрупко и многословно.
- **Использовать глобальный ProviderContainer** — работает, но нарушает scope Riverpod и делает тестирование сложнее.

Отложенная инъекция через метод — прагматичный компромисс. AuthBridge получает WidgetRef один раз, при старте приложения, и использует его до конца жизни процесса. Тестируется через AuthBridgeTestBed, который создаёт реальный widget tree с ConsumerWidget и передаёт ref в startSync.

## Чего это стоит: trade-offs

Dual-auth — не бесплатная архитектура. Вот реальная цена:

**Удвоение точек отказа.** Два провайдера — два набора rate limits, два набора инцидентов, два dashboard'а мониторинга. Обновление Appwrite SDK может сломать формат JWT — и авторизация ляжет, хотя Supabase работает безупречно.

**Сложность отладки.** "Пользователь не может загрузить данные" — это проблема в Appwrite-сессии? В Token Bridge? В Supabase JWT? В RLS-политике? Четыре слоя вместо одного. Нужны структурированные логи на каждом уровне: Appwrite auth event → Token Bridge request/response → Supabase JWT validation → RLS query. Без них — слепой дебаг.

**Onboarding разработчиков.** Новому разработчику нужно объяснить не только "как работает авторизация", а "как работают две авторизации и мост между ними". Документация AuthBridge — 4 страницы. Документация single-auth провайдера — полстраницы.

**Два набора секретов.** Appwrite API key, Appwrite project ID, Supabase URL, Supabase anon key, Supabase JWT secret, Token Bridge endpoint. Шесть значений вместо трёх. Каждое нужно хранить в CI, в .env, в секретах деплоя.

**Задержка при холодном старте.** Цепочка: Appwrite SDK init → JWT request → Token Bridge (возможен cold start Deno) → Supabase JWT → первый запрос данных. На медленном соединении это 3–4 секунды. С warm Deno-изолятом — 1–2 секунды. Решение — WarmupService, который прогревает Edge Functions при открытии приложения.

Каждый из этих trade-offs решаем. Но решаем дополнительным кодом, дополнительным мониторингом, дополнительной документацией. Dual-auth — не для каждого проекта. Оправдан, когда плюсы двух платформ перевешивают расходы на интеграцию.

## Шире, чем один проект: data residency через dual-auth

Dual-auth в JourneyBay вырос из конкретной задачи — соблюдение российского закона о персональных данных. Но сама схема применима шире.

В любой стране с жёстким регулированием данных (Россия, Китай, ряд стран ЕС с локальными требованиями, Индия с проектом DPDP Act) возникает одна и та же проблема: чувствительные данные должны лежать на локальных серверах, а приложение работает глобально. Один облачный провайдер этого не закрывает.

Dual-auth решает эту проблему архитектурно:

```
┌─────────────────────────────┐     ┌──────────────────────────────┐
│   Региональный сервер       │     │   Глобальное облако          │
│   (self-hosted)             │     │   (managed)                  │
│                             │     │                              │
│   • Учётные записи          │     │   • Маршруты, POI            │
│   • Пароли, сессии          │ JWT │   • AI-генерации             │
│   • OAuth-токены            │────►│   • Чеклисты, рекомендации   │
│   • Email, телефон          │     │   • Real-time подписки       │
│   • Персональные данные     │     │   • Edge Functions           │
│                             │     │                              │
│   Appwrite / Firebase /     │     │   Supabase / любой облачный  │
│   любой self-hosted auth    │     │   PostgreSQL-провайдер       │
└─────────────────────────────┘     └──────────────────────────────┘
```

Региональный auth-сервер хранит только то, что требует закон. Глобальная база работает с приложенческими данными без юридических ограничений. Token Bridge — единственная точка пересечения, и через неё проходят только токены, а не персональные данные.

Схема не привязана к конкретным платформам. Appwrite можно заменить на Firebase Auth, Keycloak, Auth0 с локальным деплоем. Supabase — на любой облачный PostgreSQL с RLS. Token Bridge останется тем же: принять JWT от регионального провайдера, выдать JWT для глобального.

## Итог

AuthBridge, Completer-lock и SyncRecovery превращают потенциально хрупкую связку двух BaaS-платформ в предсказуемую систему. Каждый сценарий отказа — обработан. Каждый race condition — закрыт Completer'ом. Каждый сетевой сбой — подхвачен SyncRecovery с exponential backoff.

Dual-auth — не для каждого проекта. Но если вы работаете с data residency, строите мультирегиональное приложение или используете два BaaS-провайдера по любой другой причине — три вещи стоит спроектировать до начала: единый идентификатор пользователя между системами, серверный Token Bridge, и клиентский recovery-механизм. Всё остальное — детали реализации.

---

*Нужна помощь с архитектурой авторизации или интеграцией BaaS-платформ? Я помогаю стартапам внедрять AI-решения и строить продукты — [belov.works](https://belov.works).*
