Перейти до вмісту

Основи безпеки

Supabase "Email link is invalid or has expired" на новому посиланні

На посиланні, надісланому хвилину тому, "Email link is invalid or has expired" у Supabase зазвичай означає, що поштовий фільтр використав його першим.

Vlad Tkachenko11 хв читання
Відкритий конверт, з якого наполовину стирчить квиток, і квиток уже пробитий компостером.

Коротко

  • Supabase показує "Email link is invalid or has expired", коли токен у посиланні для входу чи підтвердження вже використали, його замінив новіший лист або йому більше години. Код помилки otp_expired.
  • Якщо людина щойно отримала посилання, звична причина в поштовому фільтрі, який відкрив посилання, щоб його перевірити, і цим самим його витратив. Власна сторінка Supabase з усунення несправностей називає це найпоширенішою причиною.
  • Довший строк дії нічого не змінює. Змініть лист так, щоб відкриття адреси нічого не робило: надсилайте код, який треба ввести, або посилання на сторінку, де людину впускає кнопка.

Новий користувач реєструється, відкриває лист із підтвердженням хвилиною пізніше й натискає кнопку. Ваш застосунок відкривається на головній сторінці, досі без входу, а адресний рядок закінчується так:

#error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid+or+has+expired

Ви реєструєтеся зі своєю адресою, щоб перевірити. Посилання працює з першого разу і з кожного наступного.

Supabase каже "Email link is invalid or has expired", коли токен у посиланні з листа вже використали, замінили або коли минула його година. Якщо посилання прийшло хвилину тому, звична причина в тому, що щось використало його раніше за вашого користувача: поштовий фільтр, який відкриває посилання, щоб їх перевірити. Ваш застосунок із Lovable, Bolt, v0, Cursor, Claude Code або Replit працює так, як його збудовано, а лист, який він надсилає, можна змінити так, щоб фільтру не було чого витрачати.

Ось у чому помиляється звична порада: "попросіть спробувати ще раз" і "зробіть так, щоб посилання діяло довше" обидва не працюють проти фільтра. Друге посилання проходить через той самий фільтр і закінчується так само, хоч би скільки воно мало діяти.

Це означає, що Supabase Auth, частина Supabase, яка впускає людей, не знайшла за посиланням токена, який вона погодилася б прийняти.

Повідомлення записане в коді самого сервера Auth. У функції, яка перевіряє посилання з листа, воно повертається у двох місцях: коли жоден токен, що чекає, не збігається з токеном у посиланні, і коли токен, що збігається, надіслали раніше, ніж дозволяє ваш проєкт. Обидві відповіді мають HTTP 403 і код otp_expired, тож саме повідомлення не каже, котрий із двох випадків стався.

Ваш користувач ніколи не бачить 403. Посилання в листі веде на власну адресу вашого проєкту Supabase, яка перевіряє токен і потім відправляє браузер до вашого застосунку. Коли перевірка не проходить, Supabase додає три речі в частину адреси вашого застосунку після #: error=access_denied, error_code=otp_expired і саме повідомлення. Якщо ваш застосунок налаштований із потоком PKCE, вони є ще й у частині після ?. Поки жодна сторінка вашого застосунку їх не читає, людина бачить те, що ваш застосунок показує відвідувачу без входу, і жодного слова про причину. Сторінка скидання пароля, яка все одно показує форму, під час збереження падає з «Auth session missing!», бо посилання нікого не впустило, і про це повідомлення є окрема стаття.

Уявіть посилання як квиток, який пропускає одну людину, один раз. Хто віддасть його першим, той проходить, а квиток пробивають компостером. "Email link is invalid or has expired" означає, що людина біля дверей тримає квиток, у якому вже є дірка, або квиток, чий час минув. Якщо ваші користувачі заходять, а пізніше їх викидає, це інша помилка, у якої є своя сторінка.

Чому посилання, надіслане хвилину тому, вже використане

Бо щось відкрило його раніше за вашого користувача, а відкрити посилання з листа Supabase означає те саме, що використати його.

Посібник із шаблонів листів Supabase зараховує це до своїх обмежень: деякі поштові провайдери мають виявлення спаму чи інші функції безпеки, які заздалегідь відкривають посилання у вхідних листах. Приклад, який він наводить, це Safe Links у Microsoft Defender for Office 365. Сторінка Supabase з усунення цієї помилки називає таке завчасне відкриття найпоширенішою причиною того, що токен виглядає простроченим або недійсним ще до того, як користувач узагалі може ним скористатися.

У типовому посиланні немає жодного кроку між відкриттям і використанням. Його адреса веде на точку перевірки вашого проєкту, яка перевіряє токен і витрачає його під час першого візиту, хоч би хто його зробив. Тож фільтр віддає квиток, поки лист надходить, і проходить. Ваш користувач натискає хвилиною пізніше, і його не пускають біля дверей.

Перший візит використовує посилання. Поштовий фільтр, який відкриває його для перевірки, приходить раніше за людину.

Із цього випливають дві речі.

  • Ви самі цього не побачите. Це трапляється з людьми, чия пошта проходить через такий фільтр, і ваша власна скринька може ніколи до них не належати. Ваші тестові реєстрації й далі працюють, поки користувачі вам пишуть.
  • Обліковий запис зазвичай уже підтверджений. Під час підтвердження реєстрації візит фільтра зараховується: Supabase підтверджує адресу того, хто відкриє посилання. Якщо його відкрив фільтр, обліковий запис уже підтверджений, коли ваш користувач бачить помилку, і він може увійти з паролем, який обрав.

Три інші причини

Кожна з них дає те саме повідомлення, і кожна вимагає від вас чогось іншого.

Посилання вже використали. Ваш користувач натиснув його один раз, можливо, на телефоні, а тепер натискає той самий лист на ноутбуці. Коли посилання спрацьовує, Supabase видаляє всі посилання, які ще чекали для цього облікового запису. Його код облікових записів робить те саме прибирання після підтвердження й після посилання для входу, тож друге натискання нічого не знаходить. Квиток пробили під час першого візиту.

Його замінив новіший лист. Натискання "надіслати ще раз" створює нове посилання, і Supabase видаляє старе того самого типу, перш ніж зберегти нове. Це відбувається в коді, який створює токен. Перший лист і далі лежить у скриньці й виглядає звичайно, а його посилання не працює. Працює лише найновіший лист. Supabase також дозволяє одній адресі просити такий лист лише раз на 60 секунд, і це докладно описує ліміт листів.

Воно справді прострочене.Посібник із входу без пароля Supabase каже, що magic links типово спливають через 1 годину. На тій самій сторінці сказано, що налаштування Email OTP Expiration регулює також строк дії magic links та інших посилань із листів, зокрема для підтвердження, відновлення пароля, зміни пошти й запрошення. Хто реєструється ввечері й відкриває лист наступного ранку, побачить це повідомлення, хоч би що робив його поштовий сервіс.

Що відбувається, коли посилання відкривають на іншому пристрої?

Якщо ваш застосунок використовує потік PKCE, інший пристрій спершу видає інше повідомлення, а це приходить слідом.

PKCE розділяє квиток надвоє. Посібник із PKCE Supabase пояснює, що code verifier створюється й зберігається локально, коли починається потік Auth, і тому обмін коду треба запускати в тому самому браузері й на тому самому пристрої, де потік почався. Лист несе одну половину. Браузер, у якому ваш користувач попросив посилання, зберігає корінець.

Відкрите на телефоні, посилання все одно проходить перевірку Supabase, яка його витрачає. Потім ваш застосунок шукає корінець на телефоні й не знаходить. Клієнт JavaScript від Supabase повідомляє про це власними словами:

PKCE code verifier not found in storage. This can happen if the auth flow was initiated in a different browser or device, or if the storage was cleared.

Тож людина повертається до ноутбука й натискає там той самий лист, і Supabase відповідає "Email link is invalid or has expired", бо телефон уже дав пробити квиток. Якщо адреса, на якій відкривається ваш застосунок після робочого посилання, містить ?code=, ваш застосунок використовує PKCE, і цей випадок стосується вас.

Як дізнатися, котра це була причина?

Запитайте власну базу даних. Кожен обліковий запис у вашому проєкті є рядком у таблиці auth.users, і цей рядок записує, коли надіслали кожен тип листа і коли адресу підтвердили.

Відкрийте SQL Editor у панелі Supabase й виконайте це з адресою, яку дала людина:

select email,
       confirmation_sent_at,
       email_confirmed_at,
       recovery_sent_at
from auth.users
where lower(email) = lower('person@example.com');

Запит читає один рядок і нічого не змінює. confirmation_sent_at показує, коли пішло останнє підтвердження реєстрації. recovery_sent_at показує, коли пішов останній magic link чи останнє скидання пароля на обліковий запис, який уже існував. Саме від цих моментів Supabase відраховує годину, у тій самій функції, яка повертає помилку. Порівняйте їх із часом листа, який натиснула людина.

Що ви бачитеЩо це означає
Посилання для підтвердження не спрацювало з першого натискання, а email_confirmed_at заповненеЩось відкрило посилання раніше за людину. Обліковий запис підтверджений, тож вона може увійти зі своїм паролем
Посилання для підтвердження не спрацювало, email_confirmed_at порожнє, а натиснули його через годину з гаком після надсиланняВоно прострочене. Надішліть нове підтвердження
confirmation_sent_at або recovery_sent_at пізніше за лист, який натиснула людинаЙого замінив новіший лист. Працює лише посилання з найновішого
Magic link із найновішого листа не спрацював з першого натискання, у межах годиниЩось відкрило його першим: поштовий фільтр або сама людина на іншому пристрої

Якщо рядок показує адресу підтвердженою, а людина все одно не може увійти з паролем, Invalid login credentials є наступним повідомленням, про яке варто прочитати.

Що не змінювати, поки ви це виправляєте

Строк дії, перемикач Confirm email і те, хто має право підтверджувати користувача. Кожен з очевидних обхідних шляхів тут зупиняє повідомлення й щось відкриває.

Лишіть строк дії на годині. Фільтр відкриває посилання, поки лист надходить, тож навіть посилання, яке діє тиждень, він використає першим. Посібник Supabase із входу без пароля наполегливо не радить ставити більше ніж 86 400 секунд, тобто одну добу. Посилання для входу впускає будь-кого, хто його відкриє. Що довше воно працює, то довше пересланий лист, спільна скринька чи старе повідомлення в чиємусь архіві залишаються шляхом у цей обліковий запис.

Лишіть Confirm email увімкненим. Якщо вимкнути його, реєстрації взагалі перестануть надсилати посилання, і ця помилка зникне. Але це також означає, що будь-хто може створити обліковий запис з адресою, яку ніколи не контролював.

Не підтверджуйте користувачів із браузера. Попросіть асистента "просто позначити їх підтвердженими", і він може взятися за адміністративні функції Supabase, які вміють ставити email_confirm на обліковому записі. Довідник з адміністрування Supabase каже, що будь-який метод у supabase.auth.admin потребує ключа secret і що цей ключ ніколи не можна показувати в браузері. Секретний ключ у вашому фронтенді читає й записує кожну таблицю вашої бази даних, хоч би що казали ваші правила, для кожного, хто відкриє код вашого сайту. Це той ключ, який ця стаття про API-ключі радить замінити. Якщо не впевнені, що жоден не просочився, наш безплатний скан читає код, який ваш робочий застосунок надсилає кожному відвідувачу, і позначає секретні ключі, які там знаходить. Це займає близько 20 секунд і не потребує облікового запису: проскануйте свій застосунок.

Перевіряйте кожну адресу, яку передає ваша сторінка підтвердження. Друге рішення, яке пропонує Supabase, це ваша власна сторінка з кнопкою, на яку веде посилання такого вигляду:

<a href="{{ .SiteURL }}/confirm-signup?confirmation_url={{ .ConfirmationURL }}">

Кнопка потім веде туди, куди каже confirmation_url. Зроблена без перевірки, така сторінка відправить будь-кого, хто перейде за підробленим посиланням, на будь-яку адресу під назвою вашого застосунку. Або перевіряйте, що адреса починається з адреси вашого власного проєкту, https://<project-ref>.supabase.co/auth/v1/verify, або скористайтеся варіантом із токеном із наступного розділу, який узагалі не пропускає адресу через сторінку.

Як надіслати посилання, яке поштовий фільтр не зможе витратити

Зробіть так, щоб відкриття адреси нічого не робило, і тоді увійде лише людина, яка вводить код або натискає кнопку. Supabase документує обидва варіанти, а змінюються вони в шаблонах листів, у розділі Authentication вашої панелі. Редагувати треба два: Confirm signup і Magic Link.

Надсилайте код. Додайте {{ .Token }} у шаблон. Посібник Supabase описує його як одноразовий пароль із 6 цифр, яким можна скористатися замість посилання. Ваша сторінка просить адресу пошти й код, а потім викликає:

const { error } = await supabase.auth.verifyOtp({ email, token, type: 'email' })

Фільтр може відкрити лист. Ввести код на вашій сторінці він не може.

Ведіть на сторінку з кнопкою. Нехай посилання веде на вашу сторінку й несе хеш токена, щоб жодна адреса не мала проходити через неї:

<a href="{{ .SiteURL }}/confirm?token_hash={{ .TokenHash }}&type=email">Confirm your email</a>

{{ .SiteURL }} підставляє Site URL зі сторінки URL Configuration вашого проєкту. Якщо там досі http://localhost:3000, спершу виправте це.

Сторінка показує одну кнопку, і роботу робить лише кнопка:

const params = new URLSearchParams(window.location.search)

async function onConfirmClick() {
  const { error } = await supabase.auth.verifyOtp({
    token_hash: params.get('token_hash'),
    type: 'email',
  })
  if (error?.code === 'otp_expired') showSendNewLink()
}

Тип email підходить для обох шаблонів: перевірка Supabase шукає хеш і серед підтверджень реєстрації, і серед посилань для входу.

Сторінка не повинна викликати verifyOtp під час завантаження.Посібник Supabase про паролі будує своє посилання для підтвердження як {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }} і перевіряє його в ту мить, коли цю адресу відкривають. Це знову типове посилання за новою адресою, і фільтр відкриває його так само легко.

З типовим посиланням уже відкриття впускає. З кнопкою чи введеним кодом це робить лише дія людини.

Хоч би що ви змінили, дехто й далі натраплятиме на старе посилання, тож дайте їм вихід. Сторінка, на яку приходять ваші посилання, може прочитати помилку й запропонувати новий лист:

const hash = new URLSearchParams(window.location.hash.slice(1))
if (hash.get('error_code') === 'otp_expired') showSendNewLink()

Для реєстрації додайте рядок про те, що людина, можливо, вже підтверджена й може спробувати увійти. Якщо ваші сторінки входу пише білдер, попросіть у нього варіант обох шаблонів із кодом або кнопкою, сторінку, яка читає otp_expired і пропонує нове посилання, і жодного адміністративного ключа ніде в застосунку.

Дізнаватися про проблеми раніше, ніж напишуть користувачі

Ця помилка доходить до ваших користувачів і ніколи до вас, і інші збої теж доходять спершу до них: застосунок, що падає вночі, чи пізніший промпт, який додає ключ у код вашого застосунку.

Reeve Monitor і далі перевіряє ваш робочий застосунок ззовні:

  • чи він доступний, кожні 60 секунд, щоб "посилання не працює" можна було відрізнити від "сайт лежить"
  • усі девʼять перевірок щогодини, до трьох застосунків, разом із секретними ключами в коді, який ваш застосунок надсилає відвідувачам
  • повідомлення, коли результат змінюється, щоб ключ, який пішов із пізнішим деплоєм, дійшов до вас без того, щоб ви самі йшли дивитися
  • щомісячний звіт простою мовою про те, що він бачив

Reeve Care тримає копію вашої бази даних Supabase разом з обліковими записами:

  • зашифрована копія за розкладом, у власному сховищі Reeve в ЄС, поза вашим обліковим записом Supabase
  • схема auth, де живуть облікові записи ваших користувачів, щоб вони повернулися разом з усім іншим
  • кожна копія перевірена, перш ніж зарахуватися, підрахунком рядків у кожній таблиці
  • ваші вивантажені файли теж, щойно ви підключите доступ до Storage
  • відновлення в один клік, із безпечною копією поточного стану, зробленою заздалегідь
  • усе, що робить Monitor

Що зробити зараз

Що робити

  • Прочитайте рядок людини в auth.users. Заповнене email_confirmed_at при підтвердженні, яке не спрацювало, означає, що посилання відкрив фільтр і людина може увійти.
  • Скажіть людям користуватися найновішим листом, а якщо ваш застосунок використовує PKCE, то відкривати його на пристрої, де вони його попросили.
  • Переведіть шаблони Confirm signup і Magic Link на код або на сторінку, де кнопка викликає verifyOtp.
  • Нехай сторінка, на яку приходять ваші посилання, читає error_code=otp_expired і пропонує нове посилання.
  • Лишіть строк дії на годині, лишіть Confirm email увімкненим і тримайте секретний ключ поза браузером.

Якщо листи взагалі не доходять, ліміт листів варто виключити першим. 10-хвилинний чекліст безпеки охоплює те, що зазвичай лишається відкритим у щойно запущеному застосунку.

Поширені запитання

Чому magic link від Supabase каже, що він прострочений, одразу після надсилання?

Зазвичай тому, що щось відкрило його раніше за людину. Деякі поштові сервіси відкривають посилання у вхідному листі, щоб їх перевірити, і Supabase наводить як приклад Safe Links у Microsoft Defender for Office 365. Відкрити посилання з листа Supabase означає використати його, тож коли людина натискає хвилиною пізніше, токена вже немає, і Supabase відповідає "Email link is invalid or has expired".

Чи підтверджений обліковий запис, хоча посилання казало, що воно прострочене?

Якщо поштовий фільтр першим відкрив посилання для підтвердження реєстрації, то так. Supabase підтверджує адресу для того, хто відкриє посилання, тож візит фільтра її підтвердив. Подивіться email_confirmed_at в auth.users: якщо поле заповнене, людина може увійти з паролем, який вона обрала.

Скільки часу діє посилання з листа Supabase?

Типово одну годину. Це регулює налаштування Email OTP Expiration, і те саме налаштування діє для magic links, підтверджень реєстрації, скидання паролів, зміни пошти й запрошень. Supabase наполегливо не радить ставити більше ніж 86 400 секунд, тобто одну добу.

Чи варто зробити так, щоб посилання діяли довше?

Ні. Поштовий фільтр відкриває посилання, щойно лист надходить, тож довший строк дії не завадить йому скористатися посиланням першим. Він лише довше тримає робочим кожне невикористане посилання в кожній скриньці, а посилання впускає будь-кого, хто його відкриє, зокрема людину, якій лист переслали.

Чому посилання працює на моєму компʼютері й не працює на телефоні?

Якщо ваш застосунок використовує потік PKCE, браузер, у якому людина попросила посилання, зберігає половину того, що потрібно для входу, тож відкриття листа на іншому пристрої завершується помилкою "PKCE code verifier not found in storage". Ця спроба ще й витрачає посилання, і тому повторне натискання на першому пристрої потім каже "Email link is invalid or has expired".

Що означає error_code=otp_expired в адресі мого застосунку?

Так Supabase Auth повідомляє про посилання з листа, яке не спрацювало. Він відправляє людину до вашого застосунку з error=access_denied, error_code=otp_expired і повідомленням у частині адреси після знака #. Ваш застосунок може прочитати там error_code і показати сторінку, яка пропонує надіслати нове посилання.

Автор

Vlad Tkachenko

Засновник Reeve

Я щодня дивлюся на застосунки, зібрані в Lovable, Bolt, v0, Cursor і Replit, і на короткий список помилок, які трапляються в них знову і знову.

Більше про автора

Читати далі

Усі статті

Зробіть резервну копію бази Supabase перед наступною зміною

Reeve за розкладом зберігає зашифровану копію і відновить її за вас, якщо щось зламається.

Резервні копії Supabase

Автоматична зовнішня перевірка, а не повний аудит. Відсутність знахідок не є гарантією безпеки.