Первый же запрос к Reddit вернул 429 Too Many Requests. Первый. Не сотый, не после цикла, а самый первый за всё время существования приложения.
Я честно полез смотреть, где у меня ретраи и не долбит ли что-нибудь в фоне. Ничего не долбило. Через какое-то время выяснилось, что Reddit просто не любит клиентов без внятного User-Agent: библиотечный дефолт вида node-fetch/1.0 он считает ботом и отвечает 429, хотя причина не имеет к частоте запросов никакого отношения. Поменял заголовок на осмысленный — заработало с первой попытки.
Это была примерно двадцатая по счёту странность, и где-то на ней я перестал удивляться.
Есть спецификация OAuth 2.0, есть OpenID Connect поверх неё, обе описаны подробно и живут не первый год. По ним написаны десятки библиотек. Кажется, что подключить кнопку «Войти через» — работа на полчаса: берёшь готовый клиент, подставляешь три эндпоинта, готово. Так оно и выглядит ровно до второго провайдера.
Я собрал единую точку входа на 18 провайдеров и 26 способов авторизации — от VK и Сбера до Telegram и Steam. Почти каждый соблюдает стандарт по-своему: у одного обязательный самодельный заголовок, у другого секрет лежит в теле запроса вместо базовой авторизации, у третьего вместо OAuth протокол пятнадцатилетней давности, а у четвёртого секрет вообще не строка, а JWT, который надо перевыпускать. Ниже — каталог этих отклонений с кодом и разбор того, кто отклоняется сильнее всех.
Оговорюсь про рамки: это не гайд «как настроить вход через VK», по каждому провайдеру есть официальная документация. Это про то, чего в документации нет — почему единый обработчик на всех написать нельзя.

Почему вообще пришлось лезть в это
Контекст, чтобы было понятно, откуда задача. Я делаю Синапсия Авторизацию (id.synapsea.agency) — прослойку, которая прячет всех этих провайдеров за одним API: клиент подключает один эндпоинт и получает кнопки «Войти через VK / Яндекс / Telegram / что угодно», не разбираясь, как каждый устроен внутри. Сервис в открытом бета-тесте, и весь материал ниже накопился именно оттуда.
Смысл продукта ровно в том, чтобы описанная дальше боль оставалась на моей стороне. Так что этот текст — инвентаризация того, что пришлось спрятать за одной кнопкой.
Как выглядит «стандартный» OAuth
Чтобы было с чем сравнивать. Пользователь жмёт «Войти через X», ваш сервер формирует ссылку на страницу авторизации провайдера с client_id, redirect_uri, state и списком scope. Пользователь логинится, провайдер редиректит обратно с временным code. Ваш сервер меняет code на access_token POST-запросом на token-эндпоинт, авторизуясь парой client_id:client_secret. С этим токеном запрашивает профиль. Четыре шага, три эндпоинта. В OpenID Connect добавляется id_token — подписанный JWT, который проверяется локально, без похода за профилем.
Эталон здесь Google. Discovery-документ лежит по стандартному адресу, из него достаются все эндпоинты, id_token проверяется по опубликованному набору ключей — то есть клиент можно написать вообще не читая документацию, просто следуя спецификации:
const meta = await fetch('https://accounts.google.com/.well-known/openid-configuration') .then((r) => r.json()); const query = new URLSearchParams({ response_type: 'code', client_id: clientId, redirect_uri: redirectUri, state, nonce, scope: 'openid profile email', code_challenge: challenge, code_challenge_method: 'S256', }); const url = `${meta.authorization_endpoint}?${query}`;
Если бы все были как Google, этой статьи не было бы, а мой сервис никому не был бы нужен. Проблема в том, что как Google — примерно никто.
Кстати, state и nonce тут не для красоты, и это единственное место, где я скажу что-то нравоучительное. state — защита от CSRF: значение генерируется перед редиректом, кладётся в подписанную куку и сверяется на возврате, иначе злоумышленник подсунет жертве свой code и привяжет её аккаунт к своему. nonce — защита от повтора id_token. Оба нужны всегда, у всех провайдеров, и оба регулярно выпиливают из туториалов «для краткости».
Каталог отклонений
Вот вся картина одной таблицей, а дальше по возрастанию тяжести.
Провайдер | Протокол | Главное отклонение от стандарта |
|---|---|---|
OIDC | нет отклонений, эталон | |
Discord | OAuth 2.0 | стандартно |
Twitch | OAuth 2.0 | стандартно |
GitHub | OAuth 2.0 | scope через запятую, токен без срока |
OAuth 2.0 | требует User-Agent, иначе 429 | |
Яндекс ID | свой OAuth 2.0 | не OIDC, заголовок |
Alfa ID | OIDC | авторизация и API на разных доменах |
T-ID | OAuth 2.0 | client_secret в теле запроса профиля |
VK ID | OAuth 2.1 |
|
Сбер ID | OIDC | заголовок |
Apple | OIDC | client_secret это самоподписанный JWT ES256 |
Steam | OpenID 2.0 | не OAuth вообще, протокол из 2010-х |
Telegram | три сразу | OIDC + два разных HMAC-механизма |
ЕСИА | OAuth + ГОСТ | client_secret это ГОСТ-подпись, нужен CryptoPro |

GitHub
scope в OAuth разделяется пробелами. GitHub разделяет запятыми. Одна эта деталь ломает наивную реализацию, которая собирает строку скоупов единообразно для всех, причём ломает молча: запрос проходит, пользователь видит экран согласия, а прав у вас не тех, что вы просили.
Второе: токены GitHub по умолчанию бессрочные, expires_in в ответе просто нет. Код, который везде ждёт это поле и считает время протухания, на undefined посчитает что-нибудь вроде Date.now() + NaN и решит, что токен истёк в момент выдачи.
А третье я не предвидел совсем. Профиль от GitHub может приехать с email: null — если пользователь спрятал почту в настройках, что делают многие. Почта при этом есть, просто отдаётся отдельным эндпоинтом и только при наличии скоупа:
let { email } = profile; if (!email) { const emails = await gh('/user/emails'); // нужен scope user:email email = emails.find((e) => e.primary && e.verified)?.email ?? null; }
Проверять надо именно primary && verified. Просто «первый из списка» — это может оказаться старый рабочий адрес, на который человек не заходил три года.
С этого я начал статью, так что коротко: без осмысленного User-Agent прилетает 429, и никакой связи с частотой запросов у этого нет. Reddit хочет заголовок в собственном формате, где указано, что вы за приложение и кто автор:
User-Agent: web:synapsea-auth:v1.0 (by /u/username)
Провайдер, который различает клиентов по User-Agent, — редкость, и время уходит целиком на то, чтобы вообще заподозрить эту гипотезу. Код 429 уводит в сторону мастерски: ты идёшь читать про rate limits, находишь их документацию, считаешь свои запросы, ничего не сходится.
Яндекс
Собственный OAuth 2.0 без OpenID Connect: discovery-документа нет, id_token нет, профиль только запросом к API. И заголовок авторизации нестандартный — не Bearer, а OAuth:
const profile = await fetch('https://login.yandex.ru/info?format=json', { headers: { Authorization: `OAuth ${accessToken}` }, // не Bearer }).then((r) => r.json());
Мелочь, но код, который переиспользует один способ авторизации запроса для всех, на Яндексе спотыкается. Причём спотыкается с 401, который выглядит как «токен невалидный», и вы идёте перепроверять обмен кода, а он в порядке.
Ещё у Яндекса есть «мгновенный вход» — виджет, отдающий токен прямо во фронтенд через postMessage, минуя редирект. Токен всё равно надо проверить серверным запросом к профилю, иначе его подделает кто угодно, так что это отдельная ветка логики рядом с обычным потоком, а не замена ему.
Steam
Steam до сих пор работает на OpenID 2.0 — протоколе, который устарел больше десяти лет назад и не имеет с OAuth 2.0 ничего общего, кроме слова «open» в названии. Никакого client_secret, никакого обмена кода на токен, никакого профиля по токену. Вместо этого вам возвращают набор openid.*-параметров, и вы отправляете их обратно в Steam, чтобы он подтвердил, что подписывал именно он.
const check = new URLSearchParams(query); check.set('openid.mode', 'check_authentication'); // возвращаем всё как есть const body = await fetch('https://steamcommunity.com/openid/login', { method: 'POST', body: check, }).then((r) => r.text()); if (!/is_valid\s*:\s*true/.test(body)) throw new Error('steam: bad signature'); const steamId = query['openid.claimed_id'].match(/\/(\d+)$/)?.[1];
Этот шаг обязателен, и его любят пропускать: без него claimed_id подставляется руками в адресной строке и вы пускаете кого угодно под любым SteamID. Ответ приходит не JSON, а текстом в формате key:value по строкам — привет из 2010-х. И steamId приходится выковыривать регуляркой из хвоста URL, потому что отдельного поля с идентификатором в ответе нет.
Apple
У Apple client_secret — не строка из конфига. Это JWT, который вы генерируете сами, подписываете ES256 своим приватным ключом из личного кабинета, и живёт он максимум полгода:
const clientSecret = jwt.sign({}, privateKeyP8, { algorithm: 'ES256', keyid: keyId, // Key ID из Apple Developer issuer: teamId, // Team ID audience: 'https://appleid.apple.com', subject: clientId, // Services ID expiresIn: '180d', // жёсткий потолок — 6 месяцев });
То есть секрет надо перевыпускать в рантайме, и реализация, считающая его константой, с Apple не работает в принципе. Это ещё ладно, это хотя бы написано в документации.
А вот чего я не ожидал — так это двух вещей подряд. Первая: если запросить scope=name email, Apple переключает response_mode на form_post и присылает результат POST-запросом на ваш redirect_uri, а не GET-редиректом. Роут, написанный под GET, просто отдаёт 404, и по логам совершенно непонятно, что произошло: пользователь у Apple авторизовался, к вам вернулся, и потерялся.
Вторая: имя пользователя Apple отдаёт только при самой первой авторизации, в том самом POST, и больше никогда. Отозвали доступ, зашли заново — имени опять не будет, там останется только sub и почта. Если вы его не сохранили в первый раз, восстановить неоткуда. Я это узнал на тестовом аккаунте, где успел покликать «войти» раз пятнадцать, и потом полчаса не мог понять, почему имя приходило один раз, а дальше нет.
Плюс почта может оказаться скрытым релеем вида xxxx@privaterelay.appleid.com. Она рабочая, письма через неё доходят, но использовать её как ключ для склейки аккаунтов между провайдерами нельзя — у одного человека в Google и в Apple будут разные адреса.
VK ID
VK ID построен на OAuth 2.1 с обязательным PKCE, и это как раз современно и правильно. Но на обмене кода на токен он требует в теле два поля, которых в спецификации нет:
const body = new URLSearchParams({ grant_type: 'authorization_code', code: params.code, client_id: params.clientId, client_secret: params.clientSecret, redirect_uri: params.redirectUri, code_verifier: params.codeVerifier, device_id: params.deviceId, // не по стандарту state: params.state, // не по стандарту, state в теле обмена });
device_id приезжает в query вместе с code и его надо не потерять по дороге. state в теле обмена — вообще концептуально странно: state придуман для проверки на редиректе, на серверном обмене ему делать нечего.
Дальше профиль. Он берётся POST-запросом, где client_id передаётся в теле ещё раз, отдельно от токена — то есть идентификатор, который вы уже использовали на всех предыдущих шагах, всплывает снова там, где его не ждёшь:
const user = await fetch('https://id.vk.com/oauth2/user_info', { method: 'POST', body: new URLSearchParams({ client_id: clientId, access_token: accessToken }), }).then((r) => r.json());
И отдельная ветка — silent token из мобильного SDK: свой grant exchange_silent_token, требующий uuid рядом с самим токеном. VK ID при этом единая точка входа сразу для VK, Mail.ru и Одноклассников, так что три провайдера из моих восемнадцати частично делят механику — приятное исключение на общем фоне.
Сбер ID
Сбер требует в каждом запросе к API уникальный заголовок RqUID — 32 шестнадцатеричных символа, новый на каждый вызов. Забыли — запрос не проходит. Причём это буквально UUID без дефисов, и первая мысль «передам просто UUID» не работает:
const rqUID = randomUUID().replace(/-/g, ''); // ровно 32 hex, без дефисов
На userInfo нужен вдобавок ещё один такой же заголовок под другим именем — x-introspect-rquid. Два уникальных идентификатора запроса в одном запросе. Зачем — не знаю до сих пор.
Плюс каждый этап потока живёт на своём хосте: авторизация на одном домене, токены на другом, профиль на третьем. Конфиг провайдера, который у всех остальных состоит из трёх путей на одном базовом URL, здесь состоит из трёх разных базовых URL.
Т-Банк и Альфа
Т-Банк (бывший Tinkoff ID) на обмене кода авторизуется нормально, по HTTP Basic. А userInfo — POST, где client_id и client_secret кладутся в тело формы, при том что в заголовке уже есть Bearer-токен. Провайдер хочет и токен, и пару креденшелов одновременно, в разных местах одного запроса.
У Alfa ID точка авторизации и API-эндпоинты разнесены по двум доменам: один для редиректа пользователя, другой для серверных запросов. На фоне остального мелочь, но ещё одна вещь, которую держишь в голове.

Telegram: отдельная глава
Если бы я составлял рейтинг «сколько времени съел каждый провайдер», Telegram взял бы первое место с большим отрывом. Не потому что сложный, а потому что это три разных механизма авторизации под одним именем, и между собой они несовместимы.

Login Widget — старый способ, который до сих пор повсюду. Telegram отдаёт объект с полями пользователя и полем hash. Проверка собственная: собрать все поля кроме hash в строку key=value, отсортированную по ключу и склеенную через \n, взять SHA-256 от токена бота, и этим как ключом посчитать HMAC-SHA-256 от строки. Совпало — данные подлинные. Никакого OAuth, никакого токена, чистая криптография на вашей стороне.
Mini App — приложение внутри Telegram присылает строку initData. Проверка похожая, но не такая же: HMAC здесь берётся дважды. Сначала считается ключ как HMAC от строки-константы "WebAppData" и токена бота, и уже этим ключом подписывается строка данных.
Разница ровно в одной строке, и она стоила мне вечера:
// Login Widget: ключ = SHA256(bot_token) const secretKey = createHash('sha256').update(botToken).digest(); // Mini App: ключ = HMAC("WebAppData", bot_token) — другая операция и другой порядок const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest();
Обе строки выглядят правдоподобно, обе компилируются, и обе дают на выходе одинаковое «подпись неверна», если применить не к тому механизму. Никакой подсказки, в чём именно дело, вы не получите — HMAC либо сошёлся, либо нет.
Отладил я это в итоге тупо: захардкодил известный валидный initData из документации и гонял по нему обе схемы, пока одна не сошлась. До этого честно перечитывал свой код сборки строки данных, потому что был уверен, что напутал с сортировкой ключей. Сортировка была в порядке.
Заодно про то, о чём в туториалах пишут редко: у обоих механизмов в данных есть auth_date, и его надо проверять на свежесть. Подпись-то вечная. Перехватили один раз валидный набор полей — и логинитесь по нему хоть через год, если срок никто не смотрит:
const age = Math.floor(Date.now() / 1000) - Number(data.auth_date); if (age > 86400) throw new Error('telegram: auth_date too old');
OIDC — третий механизм, новый и наконец-то стандартный: Authorization Code с PKCE, id_token в виде JWT, проверка по опубликованным ключам. Единственный из трёх, кто похож на остальной мир.
Проблема не в том, что способов три, а в том, что пользователя они возвращают по-разному. OIDC отдаёт идентификатор и строкой в sub, и числом в id. Виджет и Mini App — только числом. Чтобы один человек, зашедший разными путями, оставался для системы одним пользователем, идентификаторы приходится приводить к общему виду. Не приведёшь — и живой человек расплодится на три аккаунта в зависимости от того, откуда зашёл. Вот это сведение трёх механизмов в один согласованный профиль и было самой муторной частью проекта: не написать каждый по отдельности, а сделать так, чтобы снаружи они выглядели одинаково.
ЕСИА: стена, на которой я остановился
Госуслуги стоят особняком, и рассказать о них стоит как о примере интеграции, которую нельзя сделать «просто написав код».
ЕСИА не пускает по обычному client_secret. Секрет там — это ГОСТ-подпись: строку из параметров запроса надо подписать по ГОСТ Р 34.10-2012, а для этого нужен CryptoPro CSP или совместимая криптобиблиотека, квалифицированная электронная подпись и регистрация вашей системы как информационной в среде ЕСИА. То есть задача звучит не как «допишите обработчик», а как «получите КЭП, поднимите криптопровайдера, зарегистрируйтесь как ИС».
Для одного сервиса на старте это неподъёмный порог. Я написал заготовку провайдера, упёрся ровно в эту стену и в итоге убрал ЕСИА из списка совсем — поэтому провайдеров восемнадцать, а не девятнадцать. Обещать вход, за которым стоит бюрократический квест с электронной подписью, нечестно, а держать нерабочую заглушку в продакшене тем более.
Оставил я её в тексте по одной причине: это лучшая иллюстрация того, как далеко «российский OAuth» уходит от собственно OAuth. Иногда ответ на вопрос «как подключить провайдера» звучит как «получите квалифицированную электронную подпись». И это, пожалуй, главный вывод про ландшафт в целом: чем ближе провайдер к банку или государству, тем дальше он от стандарта. Зарубежные сервисы в массе укладываются в OAuth 2.0 с мелкими отличиями, российские банки навешивают свои заголовки, а госсервис уходит в криптографию по национальному стандарту, где обычный OAuth-клиент бесполезен.
Что в итоге получается в коде
Наивная архитектура «один обработчик для всех, разница только в трёх URL» умирает на втором провайдере. Работающая — это набор независимых адаптеров с общим интерфейсом на входе и выходе, где каждый знает про странности своего провайдера, а наружу отдаёт одинаковый нормализованный профиль:
interface OAuthAdapter { buildAuthUrl(ctx: AuthContext): string; exchangeCode(ctx: AuthContext, query: Query): Promise<TokenSet>; fetchProfile(tokens: TokenSet): Promise<NormalizedProfile>; } interface NormalizedProfile { providerId: string; // всегда строка, даже если провайдер отдал число email: string | null; // null — нормальное значение, а не ошибка phone: string | null; displayName: string | null; avatarUrl: string | null; raw: unknown; // сырой ответ, на случай если что-то понадобится }
Поля email и phone тут нарочно nullable. Первая версия у меня требовала почту обязательно, и это развалилось на Telegram, который её не отдаёт вообще, а потом ещё раз на GitHub со спрятанным адресом. Идентификатор — всегда строка, даже когда провайдер прислал число: приводить число к строке безопасно, обратно уже нет, и в базе одно поле вместо двух.
Различается между провайдерами буквально всё: способ авторизации запроса (Basic, Bearer, заголовок OAuth, HMAC-подпись), место, где живёт client_secret (в заголовке, в теле, генерируется на лету), формат идентификатора (строка, число, оба сразу), и сам протокол (OAuth 2.0, OAuth 2.1, OIDC, OpenID 2.0, своя HMAC-схема). Общим удаётся сделать только выходной формат.
Провайдер | Тип user id | Авторизация запроса к API |
|---|---|---|
строка (sub) | Bearer | |
Яндекс ID | число |
|
VK ID | число | токен + client_id в теле |
Telegram OIDC | строка и число сразу | проверка JWT локально |
Telegram Widget | число | HMAC-подпись |
Сбер ID | строка (sub) | Bearer + RqUID |
T-ID | строка (sub) | Bearer + client_secret в теле |
Steam | число (SteamID64) | подпись OpenID 2.0 |
Восемь строк — восемь разных сочетаний, и это только два поля из десятка.

Короткий чеклист, если будете делать сами
Если вам предстоит подключать больше двух-трёх провайдеров, слой адаптеров стоит заложить сразу, даже когда первых провайдеров два и они похожи. Третий всё сломает, и переписывать под него всю систему обиднее, чем сразу написать на один интерфейс больше.
Профиль нормализуйте на границе. Провайдеры отдают данные в несовместимых форматах и под разными именами полей, и если это протечёт внутрь системы, вы получите условия вида «если это Telegram, то посмотри ещё вот сюда» в бизнес-логике. Чинить такое потом дорого.
На одного провайдера закладывайте несколько способов входа. У VK, Яндекса, Telegram, Google и Apple их больше одного, они не взаимозаменяемы, и идентификаторы из разных механизмов одного провайдера надо уметь сводить к одному человеку — иначе пользователь размножится.
client_secret не всегда статичная строка: у Apple это JWT со сроком жизни, у ЕСИА ГОСТ-подпись. Не храните его как константу в конфиге, заложите вызов функции.
И раз уж вы держите чужие client_secret от двух десятков провайдеров, шифруйте их в покое. Это самая чувствительная часть системы: утечка одного секрета — это возможность выпускать токены от имени приложения клиента.
Итог
Стандарт OAuth 2.0 существует, работает и хорошо описан. Проблема не в стандарте, а в том, что реальные провайдеры трактуют его каждый по-своему: кто добавляет обязательный заголовок, кто кладёт секрет не туда, где принято, кто приносит собственную криптографию или протокол прошлого десятилетия.
Из 18 провайдеров ровно по учебнику работает один — Google. Ещё несколько отклоняются на мелочи. Банки, госсервисы и Telegram живут каждый в своей вселенной, и единственный способ свести их вместе — написать под каждого отдельный адаптер и спрятать разницу за общим интерфейсом.
Собственно, ради того, чтобы эту разницу не разбирал каждый по отдельности, сервисы единой авторизации и существуют. Что там внутри — теперь видно.
Синапсия Авторизация сейчас в открытом бета-тесте: 18 провайдеров, 26 методов входа, подключение одним API-запросом. Попробовать на своём проекте — id.synapsea.agency, тестовый доступ бесплатный. И отдельно: если поломаете что-нибудь и пришлёте внятный баг-репорт — за подтверждённые находки я продлеваю подписку, так что покопаться в чужой авторизации можно с пользой.
Процесс разработки и прочие свои проекты пишу в личном канале — https://t.me/synapseaa. Там же кидаю находки вроде тех, что в этой статье.
Если подключали что-то из этого списка и ловили свои грабли — расскажите в комментариях. Особенно интересны провайдеры, которых у меня нет: маркетплейсы, региональные банки, что-нибудь экзотическое.

