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

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

Supabase "invalid claim: missing sub claim": ніхто не ввійшов

"invalid claim: missing sub claim": Supabase Auth отримав справжній токен без жодного користувача, ключ проєкту замість сесії.

Vlad Tkachenko10 хв читання
Бейдж на шнурку зі справжньою печаткою й порожнім рядком для імені, перед двома бейджами, на яких є імʼя та обличчя.

Коротко

  • "invalid claim: missing sub claim" означає, що Supabase Auth відповідає 403 з кодом bad_jwt. Його спитали, хто викликає, токен, який він отримав, був справжній, і в ньому не було нікого.
  • Сторінка Supabase з усунення проблем називає поширену причину: ключ API, надісланий туди, де має бути токен доступу користувача, що ввійшов. Ключ каже, який застосунок викликає, і жодної людини в ньому немає.
  • Викликайте функцію лише тоді, коли хтось увійшов, і нехай вона відповідає 401, коли ніхто не ввійшов. Підставити секретний ключ нічого не змінить, бо цей ключ теж нікого не називає.

У вашому застосунку є Supabase Edge Function, яка щось робить для людини, що ним користується: наприклад, зберігає її налаштування чи викликає OpenAI від її імені. Поки ви її тестували, вона працювала. Потім лог функції починає заповнюватися цим рядком, а застосунок показує помилку або взагалі нічого:

AuthApiError: invalid claim: missing sub claim

Пошукайте "invalid claim: missing sub claim", і вам порадять перегенерувати ключі, вимкнути Verify JWT або підставити ключ service_role. Кожна з цих порад вважає токен зламаним. З токеном усе було гаразд. Supabase перевірив його підпис і прийняв його. У ньому бракувало лише чийогось імені.

Що означає "invalid claim: missing sub claim"?

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

Токени, які читає Supabase Auth, є JWT: три шматки тексту, розділені крапками, де середній шматок містить дані, які можна прочитати. Кожен запис у цих даних називають claim. sub, скорочено від subject, це claim, у якому зберігається id користувача, і довідник Supabase про JWT описує його як ID користувача, UUID.

Повідомлення записане у власному вихідному коді Supabase Auth, у функції maybeLoadUserOrSession у файлі internal/api/auth.go. Коли ця функція запускається, підпис токена вже перевірено, і перевірку він пройшов. Далі вона шукає sub, і коли claim порожній, зупиняється з HTTP 403, кодом помилки bad_jwt і цим реченням. Відповідь, яку отримує ваш код, виглядає так:

{"code":403,"error_code":"bad_jwt","msg":"invalid claim: missing sub claim"}

Уявіть рецепцію, якій ставлять одне-єдине питання: хто цей відвідувач? Ви подаєте бейдж. Рецепція спершу перевіряє на ньому печатку, і печатка справжня. Потім читає рядок з імʼям, а рядок з імʼям порожній. Рецепції нема чим відповісти, тож вона відмовляє, і відмова вказує на порожній рядок.

Таку відповідь може отримати будь-який виклик, якому потрібна людина, що ввійшла. getUser() і updateUser() це два, де ви найімовірніше її побачите, а додавання другого фактора чи привʼязка ще одного способу входу проходять через ту саму перевірку.

Печатку перевіряють першою, й обидва бейджі її проходять. Не проходить друге питання: чиє імʼя на ньому.

Ключ називає ваш застосунок. Токен сесії називає людину.

Ключ API вашого проєкту і є тим бейджем з порожнім рядком для імені, і зробили його таким навмисно.

Документація Supabase про ключі API каже це одним рядком: за словами Supabase, ключ API розрізняє не користувачів, а застосунки. Ключ повідомляє Supabase, який застосунок викликає. Хто саме цим застосунком користується, окреме питання, і на нього відповідає токен, який Supabase Auth видає кожній людині, коли вона входить.

Довідник Supabase про JWT наводить обидва. Ключ anon старішого проєкту розшифровується ось так, і sub у ньому немає ніде:

{
  "iss": "supabase",
  "ref": "abcdefghijklmnopqrst",
  "role": "anon",
  "iat": 1640991600,
  "exp": 1640995200
}

Токен доступу людини, що ввійшла, містить її id, роль authenticated і сесію, до якої він належить (тут скорочено до рядків, що мають значення):

{
  "sub": "123e4567-e89b-12d3-a456-426614174000",
  "role": "authenticated",
  "session_id": "123e4567-e89b-12d3-a456-426614174000",
  "email": "user@example.com"
}

Ключ service_role має ту саму першу форму, з "role": "service_role", і теж без sub. Новіші ключі sb_publishable_ і sb_secret_ взагалі не є JWT, але сторінка Supabase з усунення цієї помилки каже, що Auth їх розпізнає і що claim sub у них теж немає. Тож будь-який ключ вашого проєкту, старий чи новий, публічний чи секретний, дає це повідомлення, коли його передають Auth так, ніби це людина. Та сама сторінка прямо називає помилку: випадково використати ключ API Supabase замість токена доступу Auth.

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

Де ключ надсилають замість сесії

Сюди ведуть три шляхи, і першим іде Edge Function.

1. Edge Function, яку викликають зі сторінки, де ніхто не ввійшов. Коли supabase-js має сесію, він кладе токен людини в заголовок Authorization. Коли сесії немає, покласти туди йому більше нічого, тож зі старим ключем він надсилає в цьому заголовку ключ вашого проєкту. Його файл fetch.ts так і читається: токен сесії, якщо він є, а інакше ключ.

Перевірка платформи Verify JWT пропускає такий запит. Старий ключ це JWT, підписаний вашим проєктом, а Supabase документує, що перевірка приймає й новіші ключі: саме цю шпарину докладно пояснює як перенести секрет в Edge Function. Далі ваша функція передає заголовок до Auth, і це роблять ось ці рядки:

const supabase = createClient(
  Deno.env.get('SUPABASE_URL')!,
  Deno.env.get('SUPABASE_ANON_KEY')!,
  { global: { headers: { Authorization: req.headers.get('Authorization')! } } },
)
const { data: { user } } = await supabase.auth.getUser()

Auth читає ключ, знаходить порожній рядок з імʼям і відповідає 403. Якщо функція повертає цю невдачу, ваш застосунок показує Edge Function returned a non-2xx status code, а це повідомлення чекає в лозі функції.

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

3. Ваш власний сервер передає Auth не те значення. Маршрут, який викликає getUser(token) з ключем у token, або автоматизація в n8n, Make чи Postman, яка звертається до /auth/v1/user з ключем у заголовку Authorization. Сторінка Supabase радить для цього випадку переконатися, що токен, переданий у getUser(), є токеном доступу людини.

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

Як побачити, що отримала ваша функція

Порівняйте два заголовки запиту у своєму браузері.

Відкрийте опублікований застосунок, натисніть F12, оберіть вкладку Network і повторіть те, що не вдалося. Клацніть запит, адреса якого містить /functions/v1/ і назву вашої функції, і прочитайте його заголовки запиту:

  • Authorization містить Bearer, а далі той самий рядок, що й apikey. Сторінці не було чого надсилати як сесію, тож вона надіслала ключ двічі. Ви на шляху 1 або 2.
  • Authorization містить інший довгий рядок, що починається з eyJ. Токен людини пішов. Функція чи ваш сервер загубили його дорогою, тож ви на шляху 3.
  • Заголовка Authorization немає. Тоді ви побачите не це повідомлення. Коли Verify JWT увімкнено, платформа відхиляє запит з 401 ще до запуску вашого коду, і допис про помилку non-2xx розбирає це. Коли вимкнено, Auth відповідає This endpoint requires a valid Bearer token, першим рядком таблиці нижче.

Якщо ж повідомлення в консолі браузера і звучить як Auth session missing!, жоден запит до Auth не дійшов: supabase-js не знайшла сесії й зупинилася, ще не надіславши його, і про це окрема стаття.

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

Виправлення: питати лише тоді, коли хтось увійшов

Вирішіть, що функція має робити для відвідувача, який не ввійшов, і впишіть це рішення у функцію.

Якщо нею мають користуватися лише ті, хто ввійшов, потрібні дві зміни. Сторінка викликає функцію лише після того, як хтось увійшов. А функція відповідає чітким 401, коли getUser() не знаходить користувача, щоб випадковий виклик зі сторінки без входу отримав зрозумілу відмову замість збою:

const { data: { user } } = await supabase.auth.getUser()
if (!user) {
  return new Response(JSON.stringify({ error: 'Sign in first' }), {
    status: 401,
    headers: { ...corsHeaders, 'Content-Type': 'application/json' },
  })
}

Залиште заголовки CORS і в цій відповіді, інакше браузер сховає її від вашого застосунку, і замість неї ви натрапите на "Failed to send a request to the Edge Function". Supabase також має обгортку withSupabase({ auth: 'user' }), яка робить ту саму перевірку до запуску вашого обробника; допис про Edge Function показує, як її налаштувати.

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

На власному сервері надсилайте токен доступу людини з браузера, як описує сторінка Supabase: покладіть його в заголовок Authorization: Bearer свого запиту, прочитайте на сервері й викличте supabase.auth.getUser(token) з цим значенням. Ключ вашого проєкту в цей виклик не потрапляє ніколи.

Два виправлення, після яких стає гірше

Обидва радять для цієї помилки, й обидва лишають вас у гіршому стані, ніж сама помилка.

Підставити секретний ключ. Це не може допомогти, бо service_role і sb_secret_ теж не мають sub, тож Auth відповідає так само. Шкода в тому, куди потрапляє ключ. Попросіть конструктор "взяти сервісний ключ", щоб обійти помилку, і він цілком може покласти його на сторінку, яку завантажують ваші відвідувачі, де ключ читає й змінює кожен рядок вашої бази, хоч би що казали ваші правила. Якщо це вже сталося, спершу замініть його.

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

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

Інші повідомлення тієї самої перевірки

Перевірки Auth ідуть у сталому порядку, і в кожної своє речення, тож повідомлення каже, як далеко пройшов токен.

ПовідомленняСтатус і кодЩо сталося
This endpoint requires a valid Bearer token401 no_authorizationЗаголовок Authorization: Bearer не прийшов зовсім.
invalid JWT: unable to parse or verify signature, …403 bad_jwtПечатка не та: пошкоджений токен, токен з іншого проєкту або старий ключ.
invalid claim: missing sub claim403 bad_jwtПечатка правильна, а рядок з імʼям порожній: ключ API.
invalid claim: sub claim must be a UUID400 bad_jwtІмʼя є, але не у формі id користувача Supabase.
User from sub claim in JWT does not exist403 user_not_foundТокен називає користувача, якого вже видалили.
Session from session_id claim in JWT does not exist403 session_not_foundТокен пережив свою сесію, наприклад після виходу на іншому пристрої.

Кожен рядок походить з одного файлу, internal/api/auth.go. Версія рядка з неправильною печаткою, яку повертає сама ваша база даних, PGRST301, має окремий допис. Рядок про сесію варто читати разом із дописом чому користувачів Supabase розлогінює.

Щоб перевірка лишилася після наступної зміни

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

Reeve Monitor щогодини заново сканує ваш опублікований застосунок:

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

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

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

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

Що робити

  • Відкрийте запит, що не вдався, на вкладці Network. Якщо Authorization має те саме значення, що й apikey, на момент надсилання ніхто не ввійшов.
  • Викликайте функції, яким потрібна людина, лише після входу, і нехай функція відповідає 401, коли getUser() не знаходить користувача.
  • На власному сервері передавайте в getUser() токен доступу людини. У ключі вашого проєкту користувача немає.
  • Лишіть секретний ключ там, де він є. Він теж нікого не називає, а на сторінці читає й змінює кожен рядок.
  • Залиште перевірку того, хто викликає. Функція, що бере id користувача із запиту, вірить будь-кому.

Якщо ваш застосунок показує Edge Function returned a non-2xx status code, поки це повідомлення лежить у лозі, функція передає відмову далі, і той допис читає відповідь звідти. Десятихвилинний чекліст безпеки охоплює, що ще новий застосунок зазвичай лишає відкритим.

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

Що означає "invalid claim: missing sub claim" у Supabase?

Це означає, що Supabase Auth спитали, який користувач викликає, і дали йому токен, у якому немає жодного користувача. Сам токен був справжній і пройшов перевірку підпису. У ньому бракувало claim sub, поля, у якому зберігається id користувача. Саме так виглядають ключі API вашого проєкту, тому звична причина полягає в ключі, надісланому туди, де має бути токен користувача, що ввійшов.

Це те саме, що "Auth session missing!"?

Ні. "Auth session missing!" походить від supabase-js у браузері, який не знаходить жодної сесії й навіть не надсилає запит. "invalid claim: missing sub claim" повертає Supabase Auth, який отримав запит і знайшов ключ там, де мав бути токен користувача. Перше зазвичай означає сторінку, де ніхто не ввійшов; друге означає функцію чи сервер, що передали ключ далі.

Чи допоможе вимкнути Verify JWT?

Ні. Verify JWT це перевірка, яку Supabase робить для Edge Function до того, як запуститься ваш код, а це повідомлення приходить пізніше, від Supabase Auth, коли ваш код питає, хто викликає. Якщо вимкнути перевірку платформи, до вашого коду почнуть доходити ще й запити зовсім без токена, а помилка залишиться саме там, де була.

Чи спричиняє цю помилку анонімний вхід?

Ні. Анонімний вхід створює справжнього користувача з власним id, тож його токен має claim sub і is_anonymous зі значенням true. Ключ anon це інша річ, попри схожу назву: ключ проєкту без жодного користувача всередині, і саме він дає це повідомлення.

Чому на одних сторінках написано 401, а на інших 403?

Сторінка Supabase з усунення проблем для цього повідомлення має 401 у заголовку. Поточний код Supabase Auth відповідає 403 з кодом помилки bad_jwt. Якщо ваш код вирішує, що робити, лише за статусом, перевіряйте також код помилки й повідомлення.

Автор

Vlad Tkachenko

Засновник Reeve

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

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

Читати далі

Усі статті

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

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

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

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