Пользователи перенесены, строки на месте, счётчик в таблице совпадает со счётчиком в старом проекте. Но стоит нажать «Войти через Google», как браузер после согласия возвращается на старый адрес. А рядом у коллеги в соседней вкладке приложение по-прежнему считает его вошедшим, хотя запросы идут уже на новый сервер. Две странности выглядят как две разные поломки, а на деле это два разных слоя одной системы, и чинятся они по-разному. Чтобы не гоняться за симптомами, сначала полезно разобрать, из чего вообще состоит «вход» в Supabase Auth.
Четыре вещи, которые называют одним словом «auth»
В разговорах о миграции «авторизацией» называют всё подряд. Для переноса это опасная неточность: у каждой сущности своя роль, и перенос одной не переносит остальные.
- Запись пользователя — строка в хранилище Auth: идентификатор, адрес почты, привязки к внешним провайдерам, метаданные. Это память системы о том, кто есть кто.
- Настройки OAuth-провайдеров — идентификатор и секрет приложения у Google, GitHub или другого провайдера, а также список адресов, на которые провайдер имеет право вернуть пользователя. Эти настройки живут отчасти у самого провайдера, отчасти в конфигурации Auth.
- API-ключ приложения — то, чем клиент или серверный код представляется самому API: publishable-ключ во фронтенде, secret-ключ на сервере. Он отвечает на вопрос «какое приложение обращается», а не «какой человек вошёл».
- Материал проверки пользовательского JWT — ключ подписи, которым подписан токен сессии, и ключ или набор ключей, которым эту подпись проверяют. Он отвечает на вопрос «действительно ли этот токен выпустила наша система Auth».
Документация Supabase прямо разделяет две последние сущности: в новой системе signing keys ключ подписи JWT и publishable/secret API keys управляются независимо (Supabase Auth: JWT signing keys). Практический вывод для переезда простой: можно выпустить на новой стороне свежие API-ключи и при этом не коснуться подписи пользовательских токенов, а можно наоборот. Смена одного не подразумевает смену другого, и ожидать, что «раз ключи приложения новые, то и сессии как-нибудь подхватятся», не стоит.
Таблица ниже фиксирует, что каждая сущность делает при переносе и что с ней происходит, если её не перенести.
| Сущность | Что отвечает | Если не перенесена или заменена |
|---|---|---|
| Запись пользователя | Кто этот человек и какие у него привязанные способы входа | Человек не найден и при входе получит новый профиль или отказ |
| Настройки OAuth | Куда провайдер вернёт пользователя и каким приложением представится | Возврат на старый адрес либо ошибка несоответствия redirect URI |
| API-ключ приложения | Какое приложение обращается к API | Клиент получает отказ ещё до вопроса о пользователе |
| Материал проверки JWT | Подлинность и срок действия токена сессии | Старые токены отвергаются, пользователи просят войти заново |
Дальше мы пройдём по этим сущностям в том порядке, в котором их встречает реальный запрос.
Две цепочки входа: пароль и возврат от провайдера
Парольный вход и OAuth выглядят для пользователя похоже, но маршрут у них разный, и ломаются они в разных местах.
Парольный вход: одна точка и один ответ
Клиент отправляет почту и пароль на endpoint токенов Auth нового сервера, обычно это путь вида /auth/v1/token?grant_type=password за вашим API-шлюзом. Auth ищет запись пользователя, сверяет пароль с хранимым представлением и, если всё сходится, выпускает пару: короткоживущий access token (JWT) и refresh token. Цепочка замкнута внутри вашего сервера. Внешнего участника нет, поэтому здесь ломаются только три вещи: нет записи, не сходится проверка пароля, клиент не прошёл по API-ключу.
Вот на проверке пароля и стоит остановиться. Сам факт, что строки пользователей скопированы, не доказывает, что пароль будет принят. Хранимое представление пароля должно быть прочитано новой версией Auth так же, как старой. Поэтому обещание «все войдут со старыми паролями» нужно проверять опытным путём: взять несколько тестовых учётных записей с известными паролями, перенести их тем способом, который вы выбрали, и войти. Если способ экспорта и версии компонентов вам не подтверждены, готовьте запасной путь: письмо для установки нового пароля. Это не поражение, а нормальный сценарий, и мы вернёмся к нему в конце.
OAuth: три участника и два адреса
В OAuth-цепочке больше звеньев. Приложение просит у Auth начать вход, Auth отправляет браузер к провайдеру, провайдер после согласия возвращает браузер на callback-адрес Auth (это адрес вашего Auth-сервера, например /auth/v1/callback), а Auth, завершив обмен кодом, перенаправляет браузер уже в приложение на адрес redirectTo. То есть адресов возврата два, и принадлежат они разным системам:
- Callback-адрес Auth. Его регистрируют у провайдера в консоли приложения. Если там остался адрес старого проекта, провайдер вернёт пользователя на старый сервер, и вы увидите ровно ту картину, с которой начиналась эта статья.
- Адрес приложения после входа. Его задаёт клиент параметром
redirectTo, а Auth проверяет по разрешённому списку.
Когда симптом «после OAuth возвращает на старый адрес» объясняют единственной причиной, чаще всего ошибаются. Сначала нужно понять, какой из двух возвратов уходит не туда. Если браузер после согласия оказывается на старом домене Auth, виновата регистрация у провайдера. Если он оказывается на новом Auth, но затем попадает на старый адрес приложения, дело в redirectTo либо в Site URL, и это уже настройки нового сервера.
Практичный способ разобраться: открыть вкладку сети в браузере, начать вход и записать цепочку перенаправлений по порядку. Каждое звено — это хост и путь. Там, где хост впервые оказывается «не вашим новым», и находится ошибка. Такой разбор занимает минуты, а гадание по логам — часы.
Redirect URL, Site URL и почтовые действия
Документация формулирует правило строго: redirectTo должен соответствовать списку разрешённых redirect URLs, а Site URL задаёт адрес по умолчанию и важен для подтверждения email и восстановления пароля (Supabase Auth: Redirect URLs). Из этого вытекают наблюдаемые последствия, которые легко проверить на репетиции.
Если redirectTo не входит в список, Auth не отправит пользователя туда, куда просит клиент, и вместо этого воспользуется адресом по умолчанию. Для пользователя это выглядит как «вошёл, но оказался на главной, а не на нужной странице», либо, если Site URL остался старым, на чужом домене. Отсюда первый критерий приёмки: после входа с каждым провайдером человек оказывается на той странице нового приложения, откуда начинал.
Второй момент касается писем. Ссылки подтверждения почты и восстановления пароля строятся от Site URL. Если он указывает на старый адрес, письмо о сбросе пароля, отправленное с нового сервера, приведёт человека на старый. Это особенно неприятно, потому что в обычной проверке входа письма не участвуют. Баг проявится у первого же пользователя, забывшего пароль, то есть тогда, когда вы ещё и предложили ему сбросить пароль после переезда.
О формате списка: для production документация советует точные адреса, а wildcard удобен для preview-окружений. Применить это разумно можно так. Для боевого домена внесите конкретные пути: корень приложения и страницу, на которую вы отправляете после входа. Для окружений предпросмотра оставьте шаблон, но не смешивайте его со списком боевой среды, иначе чужой поддомен под ваш шаблон станет допустимым адресом возврата. Пока вы репетируете миграцию на тестовом поддомене, вам понадобятся оба варианта, и после переключения тестовые записи стоит убрать.
Теперь соберите список того, что надо привести в соответствие на новой стороне:
- Site URL нового Auth указывает на боевой домен приложения.
- В списке redirect URLs точные адреса возврата для боевого домена.
- У каждого OAuth-провайдера зарегистрирован callback-адрес нового Auth-сервера, а старый адрес пока оставлен, если нужен откат (провайдеры обычно допускают несколько адресов, но уточните это в консоли конкретного провайдера).
- Идентификатор и секрет OAuth-приложения внесены в конфигурацию нового Auth.
- Исходящая почта настроена так, чтобы письма подтверждения и сброса уходили с нового сервера и вели на новый адрес.
Последний пункт заслуживает отдельной проверки: отправьте тестовое письмо сброса на ящик, который вы контролируете, откройте ссылку и убедитесь, что она ведёт на новый домен и завершает операцию.
Один старый токен на новом endpoint
Теперь вторая странность из начала: вкладка, где человек остаётся «вошедшим». Чтобы понять, почему она ведёт себя так, проследим судьбу одного конкретного старого access token.
Токен лежит в браузере. Клиентская библиотека кладёт его в заголовок Authorization: Bearer … и отправляет запрос. Допустим, адрес API в приложении уже переключили на новый сервер. Что происходит дальше, зависит от того, какой компонент принимает запрос.
- Запрос приходит на шлюз нового сервера. Он смотрит на API-ключ приложения и пропускает запрос дальше. Это проверка «какое приложение», она не касается подписи пользовательского токена.
- Запрос к данным (REST-слою) или к Auth содержит пользовательский JWT. Компонент, которому он адресован, проверяет подпись токена, срок действия (поле
exp) и, как правило, ожидаемые условия: издателя и аудиторию, если они проверяются в вашей конфигурации. - Если подпись сходится с ключом, который известен новому серверу, токен принят. Если нет, запрос отвергается как неавторизованный.
Вот в шаге 2 и заключена вся суть. Токен подписан ключом, который был у старого проекта. Новый сервер примет его только при условии, что знает соответствующий материал проверки. Записи пользователей тут ничего не решают: токен проверяется криптографически, а не поиском строки. Именно поэтому нельзя вывести совместимость сессий из одного факта переноса пользователей. Копирование таблиц отвечает на вопрос «кто есть кто», а не «каким ключом подписаны выданные токены».
На практике возможны три исхода для нашего одного токена:
| Условие | Результат проверки | Что увидит пользователь |
|---|---|---|
| Новый сервер знает ключ, которым подписан токен, и срок не истёк | Подпись сходится, токен принят | Работает без повторного входа |
| Новый сервер использует другой ключ подписи | Подпись не сходится, токен отвергнут | Ошибка авторизации, приложение просит войти |
| Подпись сходится, но срок истёк | Токен просрочен | Клиент пытается обновить сессию refresh token |
Третья строка таблицы тоже интересна. Access token живёт недолго, и по его истечении клиент обращается к Auth с refresh token. Здесь проверка уже другая: refresh token не подписанный JWT, который проверяется ключом, а запись в хранилище Auth, и новый сервер найдёт её только если сессионные данные тоже перенесены вместе с пользователями. Если же они не перенесены, обновление не удастся, и пользователь так или иначе окажется на экране входа. Поэтому «сессия пережила переезд» требует выполнения двух условий сразу: материал проверки JWT совместим и сессионные записи доступны новому Auth. Одного без другого мало.
Теперь можно объяснить странное поведение вкладки коллеги. Если старый токен ещё не истёк, а какой-то компонент всё ещё принимает его по прежним ключам (например, клиент по-прежнему обращается к старому проекту, или часть сервисов всё ещё настроена на старый материал проверки), приложение выглядит «вошедшим». При этом на новом сервере тот же токен может не пройти проверку. Получается разрыв: одна часть системы считает пользователя вошедшим, другая нет. Обнаруживается он не на странице входа, а на запросах к данным, когда одни экраны грузятся, а другие отвечают отказом.
Отсюда прикладное правило репетиции: возьмите реальный токен тестового пользователя из старой системы и отправьте его на новый endpoint, например в запрос к данным, который требует авторизации. Ответ прямо покажет, принимает ли новая сторона подпись. Не отключайте проверку подписи ради удобства: тест нужен именно для того, чтобы увидеть настоящую реакцию.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Общий секрет против signing keys
Почему вообще возможна несовместимость? Потому что у Supabase существуют две модели подписи, и документация подчёркивает, что у legacy JWT secret и новой системы signing keys разные свойства управления и ротации (Supabase Auth: JWT signing keys). Для нашей задачи достаточно понимать следствия различия.
В старой модели подпись строится на общем секрете. Один и тот же секрет подписывает и проверяет токены. Любой компонент, которому нужно проверять токены, должен знать этот секрет. Если вы разворачиваете self-hosted стек и задаёте ему тот же секрет, что был у старого проекта, ваш новый Auth сможет проверить подписи прежних токенов. Но это возможно только если вы этот секрет знаете и можете его перенести. Если проект использовал значение, которого вы не получили, переиспользовать его не получится, и придётся искать другой путь.
В новой системе ключи подписи управляются отдельно от API-ключей, и проверка строится вокруг идентификатора ключа и опубликованных открытых данных проверки. Токен несёт указание, каким ключом он подписан, а проверяющая сторона выбирает подходящий ключ. Ротация в такой модели предусмотрена внутри системы: старый ключ можно оставить для проверки, а новые токены подписывать новым. Но здесь легко ошибиться: описанная в документации ротация ключей внутри Supabase не является инструкцией для переноса из облака в self-hosted стек. Она объясняет, как меняются ключи в пределах одной системы, где управление ключами остаётся за ней.
Что отсюда следует для переезда:
- Нельзя рассчитывать, что закрытый ключ подписи можно экспортировать из облачного проекта и поставить на свой сервер. Если у вас нет подтверждённого способа получить нужный материал, считайте, что токены старого проекта новым сервером проверены не будут.
- Если ваш старый проект работал на legacy-секрете и вы действительно владеете его значением, проверьте на репетиции, принимает ли новая конфигурация токены, подписанные им. Положительный результат опытной проверки на ваших версиях и есть подтверждение, а не предположение.
- Совместимость версий компонентов — отдельное условие. Даже при одинаковом материале проверки разные версии Auth могут по-разному обращаться с условиями токена, например с издателем. Репетиция на вашей реальной связке покажет это раньше пользователей.
Если подтвердить совместимость не удаётся, выбор честный и несложный: считать, что все действующие сессии при переключении закончатся, и подготовить для этого аккуратный сценарий.
Когда сессию не сохранить: повторный вход без второго профиля
Принудительный повторный вход звучит как поражение, но его можно сделать почти незаметным. Задача сводится к тому, чтобы человек вошёл в тот же профиль, а не создал новый. Тут и скрывается главный риск, потому что две дорожки входа ведут себя по-разному.
Пароль: сброс вместо угадывания
Если перенос паролей подтверждён тестом, вход со старым паролем сработает как обычно. Если нет, пользователю нужно письмо для установки нового пароля. Письмо придёт на ту же почту, которая записана в профиле, поэтому и новый пароль привяжется к существующей записи. Вот почему проверка Site URL и почтовых действий из предыдущего раздела критична: письмо должно вести на новый адрес и завершаться на нём.
OAuth: идентичность держится на привязке
У OAuth-пользователя запись связана с идентичностью у провайдера: провайдер отдаёт стабильный идентификатор человека, а Auth хранит эту привязку вместе с пользователем. Если привязки перенесены вместе с записями, повторный вход через Google приведёт человека к прежнему профилю. Если же привязки потеряны, а почта совпадает, поведение зависит от настроек вашей установки по связыванию учётных записей, и безопасно рассчитывать на него без проверки нельзя. Не угадывайте: на репетиции войдите через каждого провайдера под тестовой записью, существовавшей до переноса, и посмотрите, остался ли идентификатор пользователя прежним.
Практичный критерий здесь одно сравнение: идентификатор пользователя до и после входа. Он должен совпасть. Если он отличается, значит, создан второй профиль, и данные, привязанные к старому идентификатору (заказы, настройки, права), человеку не видны. Это самая коварная ошибка миграции Auth: всё «работает», вход проходит, но на экране пустой аккаунт.
Как смягчить момент разрыва
- Предупредите пользователей заранее одной фразой в приложении: после обновления потребуется войти заново.
- Сделайте так, чтобы приложение при ошибке авторизации очищало локальную сессию и вело на экран входа, а не показывало пустые списки с невнятными ошибками.
- Если у вас есть выбор времени, переключайте в период, когда одновременно вошедших мало: срок жизни access token всё равно ограничен, а главным образом повторный вход нужен тем, у кого перестала работать цепочка обновления.
- Оставьте старую сторону доступной на время отката, но не принимайте на ней новые регистрации после переключения, иначе у вас появится два источника правды о пользователях.
Последний пункт важен отдельно. Пока обе стороны принимают регистрации, один и тот же человек может завести запись и там, и там, а потом вы будете вручную сливать два профиля. Определите момент, после которого записи создаёт только новая сторона, и держите его как жёсткую границу.
Репетиция, которую стоит провести до переключения
Всё сказанное сводится к небольшой серии проверок на копии, которую удобно развернуть на отдельной VPS. Self-hosted Auth со связанными сервисами — это несколько контейнеров, и им нужна машина с запасом по памяти и быстрым диском. Подойдёт VPS MAATRIX на NVMe с root-доступом: на нём можно поднять тестовый стек, прогнать репетицию и затем решить, остаётся ли он боевым. Ресурсы тарифов в каталоге (от Ba с 1 vCPU и 2 ГБ RAM до Djed с 16 vCPU и 32 ГБ) не равны обещанию ёмкости, поэтому размер подбирайте по замеру своей нагрузки, а не по названию тарифа. Подробности о переносе самой базы и файлов разобраны в материале про переезд с Supabase Cloud на VPS; здесь мы держимся только Auth.
Что именно прогнать на копии:
- Парольный вход. Три тестовых пользователя с известными паролями входят на новом endpoint. Фиксируем: вошли или нет, остался ли идентификатор прежним.
- OAuth с каждым провайдером. Записываем цепочку перенаправлений в браузере и убеждаемся, что оба возврата, на callback Auth и на адрес приложения, ведут на новые хосты.
- Письмо сброса пароля. Ссылка ведёт на новый домен и завершает смену пароля.
- Старый токен на новом endpoint. Берём реальный access token из старой системы и отправляем на новый API с запросом, требующим авторизации. Принят или отвергнут — запоминаем.
- Обновление сессии. Берём пару токенов старой системы, ждём истечения access token или подставляем просроченный и смотрим, обновится ли сессия по refresh token на новой стороне.
- Идентификатор после повторного входа. Для пользователя, у которого сессия не сохранилась, сравниваем идентификатор до и после повторного входа.
Результаты шагов 4 и 5 определяют сценарий переключения. Если токены приняты и обновляются, сессии можно сохранить, и пользователи ничего не заметят. Если нет, вы заранее знаете, что повторный вход неизбежен, и успеваете подготовить сообщение и обработку ошибки. Если принято одно, а второе нет, вы увидите то самое «половинчатое» состояние и сможете не допустить его в боевой среде.
Критерии переключения и откат
Переключать адрес API стоит, когда выполнены условия: идентификатор не меняется ни в паролях, ни в OAuth; письма ведут на новый домен; поведение старых токенов вам известно и описано в плане; список redirect URLs в боевой среде состоит из точных адресов. Откат в этой задаче простой, если вы заранее не закрыли путь назад: вернуть адрес API на старую сторону и оставить у провайдеров оба callback-адреса. Токены, выпущенные новой стороной за время работы, на старой стороне могут не пройти проверку по той же причине, по какой старые не пройдут на новой, поэтому откат тоже может означать повторный вход. Это стоит включить в план и не удивляться.
Человек возвращается к себе
Представим конкретного пользователя, Марину. У неё в приложении привычный аккаунт, привязанный к Google, с несколькими сохранёнными проектами. В день переключения она открывает приложение, и вместо списка проектов видит экран входа. Токен её сессии подписан старым ключом, новый сервер его не принял, и приложение, как мы договорились, аккуратно сбросило локальную сессию.
Марина нажимает «Войти через Google». Браузер уходит к провайдеру, провайдер видит зарегистрированный callback нового Auth и возвращает её туда. Auth находит привязку идентичности, перенесённую вместе с записью пользователя, и обнаруживает знакомого человека, а не нового. Затем перенаправляет её на адрес, разрешённый в списке redirect URLs, ровно на ту страницу, откуда она начинала. Идентификатор пользователя тот же, что вчера, и проекты, привязанные к нему, на месте.
Для неё весь переезд уместился в одну лишнюю кнопку. Для вас признаком успеха стало не то, что сессии пережили перенос, а то, что второго профиля с пустым списком проектов не появилось нигде.