Как добавить вход через GitHub
Добавьте вход через GitHub в Node и Express. Настройте OAuth с state и PKCE, безопасно обменивайте коды, получайте подтвержденную почту и обновляйте токены.
Чтобы добавить вход через GitHub, перенаправьте пользователя на https://github.com/login/oauth/authorize, получите временный code на свой callback URL и обменяйте этот код на токен доступа, отправив с сервера POST-запрос на https://github.com/login/oauth/access_token.
В одной вкладке у вас открыты настройки OAuth-приложения, в другой — обработчик маршрута, а большинство руководств перекладывают всю работу на SDK. В этом руководстве серверный поток реализуется на Node с помощью fetch и Express, без OAuth-библиотек. Базовая модель описана в статье All about OAuth 2.0. Здесь рассматриваются state и PKCE, проблема пустого email, области доступа (scopes), а также изменения в redirect URI и обновлении токенов, которые GitHub выпустил 14 августа 2026 года.
Ключевые выводы
- Код авторизации GitHub истекает через 10 минут после выдачи, а обмен кода должен выполняться на сервере, поскольку для него нужен client secret.
- Генерируйте
stateна сервере, храните его в httpOnly-cookie и прерывайте обработку callback, если возвращённое значение отличается. Это защищает от login CSRF. GET https://api.github.com/userвозвращаетemail: null, если у пользователя нет публичного адреса, поэтому запрашивайтеuser:emailи используйте основной подтверждённый адрес изGET /user/emails.- После обновления GitHub от 14 августа 2026 года OAuth-приложение может зарегистрировать до 10 redirect URI, а новые приложения по умолчанию выдают токены доступа на 8 часов и refresh-токены на 6 месяцев.
Регистрация OAuth-приложения GitHub
Создайте OAuth-приложение в разделе Settings → Developer settings → OAuth Apps, затем скопируйте client ID и сгенерируйте client secret. Все поля перечислены в руководстве GitHub по регистрации.
Начиная с релиза от 14 августа 2026 года, одно OAuth-приложение может содержать до 10 redirect URI, которые добавляются кнопкой Add callback URL. Одним приложением можно покрыть продакшен, preview-деплои и локальную разработку. Для локальной работы зарегистрируйте http://127.0.0.1:3000/auth/github/callback. localhost тоже работает, но в рекомендациях GitHub по loopback-адресам отмечается, что RFC по OAuth рекомендует использовать литерал 127.0.0.1.
Сопоставление по шаблону (wildcard matching) теперь настраивается для каждого redirect URI отдельно. Если до 3 августа 2026 года у вашего приложения был только один callback URL, GitHub включил для него wildcard matching, чтобы сохранить прежнее поведение перенаправлений. Отключите эту настройку, если только вы не контролируете все поддомены и пути под этим URL.
Перенаправление на endpoint авторизации GitHub
Кнопка входа ведёт на серверный маршрут. Этот маршрут генерирует state и PKCE verifier, сохраняет оба значения в короткоживущих cookie и перенаправляет на GET https://github.com/login/oauth/authorize.
import crypto from 'node:crypto';
import express from 'express';
import cookieParser from 'cookie-parser';
const app = express();
app.use(cookieParser());
const { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_REDIRECT_URI } = process.env;
// GITHUB_CLIENT_SECRET is read only here, on the server. Never ship it to the browser.
const isProd = process.env.NODE_ENV === 'production';
const tempCookie = {
httpOnly: true,
secure: isProd,
sameSite: 'lax',
maxAge: 10 * 60 * 1000, // matches the 10-minute code lifetime
path: '/auth/github',
};
app.get('/auth/github', (req, res) => {
const state = crypto.randomBytes(32).toString('base64url');
const verifier = crypto.randomBytes(32).toString('base64url'); // 43 chars
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
res.cookie('gh_state', state, tempCookie);
res.cookie('gh_verifier', verifier, tempCookie);
const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: GITHUB_REDIRECT_URI, // one value per environment
scope: 'read:user user:email',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});
Параметры авторизации GitHub поддерживают только один метод PKCE — S256, а plain отклоняется. Challenge должен быть 43-символьным значением SHA-256 — именно столько даёт кодирование SHA-256-дайджеста в base64url. Если у ваших пользователей часто бывает несколько аккаунтов GitHub, добавьте prompt=select_account, чтобы GitHub всегда показывал окно выбора аккаунта.
Проверка state для защиты от login CSRF
Параметр state — это случайное значение, которое невозможно угадать. Ваш сервер создаёт его и сохраняет в сессии или в httpOnly-cookie. Когда GitHub выполняет обратное перенаправление, сравните сохранённое значение с state из callback. Если они различаются, прервите вход и не обменивайте код.
Без такой проверки приложение уязвимо к login CSRF — атаке, при которой жертва входит в аккаунт атакующего:
- Атакующий запускает процесс входа через GitHub в вашем приложении со своим аккаунтом GitHub.
- Он подтверждает доступ, но останавливается до выполнения callback, сохраняя действительный
code. - Он заставляет браузер жертвы загрузить
https://yourapp.com/auth/github/callback?code=ATTACKER_CODE, например через скрытое изображение или автоматически срабатывающую ссылку. - Ваш сервер обменивает код и выполняет вход жертвы под аккаунтом атакующего. Хуже того, если жертва уже была авторизована, сервер привязывает GitHub-идентичность атакующего к аккаунту жертвы.
- Всё, что жертва затем сохраняет, попадает в аккаунт, который контролирует атакующий. А привязанная идентичность позволяет атакующему впоследствии входить от имени жертвы.
С state шаг 4 не срабатывает. В браузере жертвы либо нет cookie gh_state, либо есть cookie, значение которой не совпадает со значением атакующего, поэтому сервер отклоняет запрос. PKCE добавляет вторую привязку: код работает только вместе с code_verifier, сохранённым в браузере, который начал процесс. RFC 6749 определяет state именно для этой цели, а OAuth 2.0 Security Best Current Practice рекомендует PKCE для всех клиентов. Подробнее см. рекомендации по state и PKCE в статье Securing OAuth best practices.
function statesMatch(received, stored) {
if (typeof received !== 'string' || typeof stored !== 'string') return false;
const a = Buffer.from(received);
const b = Buffer.from(stored);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Важны и атрибуты cookie. HttpOnly закрывает доступ к значению из скриптов, а Secure ограничивает передачу cookie протоколом HTTPS. SameSite=Lax позволяет cookie пережить обратное перенаправление верхнего уровня от GitHub. Поскольку cookie gh_state одна, вход во второй вкладке перезапишет значение из первой, поэтому показывайте понятное сообщение с предложением повторить попытку, а не пустую ошибку.
Обработка callback и обмен кода
Callback проверяет state, удаляет временные cookie и отправляет код POST-запросом на https://github.com/login/oauth/access_token с заголовком Accept: application/json. Без этого заголовка token endpoint GitHub возвращает тело в формате form-encoded. Код истекает через 10 минут после выдачи, поэтому обменивайте его сразу.
app.get('/auth/github/callback', async (req, res) => {
const { code, state } = req.query;
const { gh_state: savedState, gh_verifier: verifier } = req.cookies;
res.clearCookie('gh_state', { path: '/auth/github' });
res.clearCookie('gh_verifier', { path: '/auth/github' });
if (typeof code !== 'string' || !verifier || !statesMatch(state, savedState)) {
return res.status(400).send('Sign-in failed. Please try again.');
}
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code,
redirect_uri: GITHUB_REDIRECT_URI, // must match the authorize request
code_verifier: verifier,
}),
});
const token = await tokenRes.json();
if (token.error || !token.access_token) {
return res.status(400).send('Sign-in failed. Please try again.');
}
const profile = await getGitHubProfile(token.access_token);
const user = await upsertUserByGitHubId(profile); // key on profile.id
await saveGitHubTokens(user.id, token); // server-side only
await createSession(res, user.id); // your own session cookie
res.redirect('/');
});
Выполняйте этот обмен на сервере: для него нужен client secret, а этот секрет никогда не должен попадать в браузер. После успешного обмена выдайте собственную сессионную cookie, а токен GitHub храните на сервере. Компромиссы при выборе хранилища разобраны в статье Cookies vs localStorage for JWT auth. Финальный res.redirect('/') убирает code и state из адресной строки, так что нажатие кнопки «Назад» не приведёт к повторному использованию уже потраченного кода.
Записи сессий (session replay) неудачных попыток входа через GitHub показывают всю последовательность: клик, GitHub, callback, страница ошибки. По этой последовательности можно отличить несовпадение state от истёкшего кода. Настройте любые инструменты session replay, аналитики и логирования так, чтобы они маскировали query-параметры code и state.
Получение данных пользователя и подтверждённого email
Чтобы идентифицировать пользователя, вызовите GET https://api.github.com/user с токеном. Endpoint аутентифицированного пользователя возвращает email: null, если пользователь не указал публичный адрес. Кроме того, для получения приватного ответа OAuth-токенам требуется scope read:user.
const ghHeaders = (accessToken) => ({
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github+json',
'User-Agent': 'your-app-name',
});
async function getGitHubProfile(accessToken) {
const userRes = await fetch('https://api.github.com/user', { headers: ghHeaders(accessToken) });
if (!userRes.ok) throw new Error(`GET /user failed: ${userRes.status}`);
const user = await userRes.json();
let email = user.email;
if (!email) {
const emailsRes = await fetch('https://api.github.com/user/emails', { headers: ghHeaders(accessToken) });
if (emailsRes.ok) {
const emails = await emailsRes.json();
email = emails.find((e) => e.primary && e.verified)?.email ?? null;
}
}
return { id: user.id, login: user.login, name: user.name, email };
}
Со scope user:email GET /user/emails возвращает все адреса вместе с флагами primary и verified. Выбирайте запись, у которой оба флага равны true, и никогда не связывайте аккаунты по неподтверждённому адресу. Идентифицируйте пользователей по числовому id, поскольку логины и email могут меняться. REST API GitHub отклоняет любые запросы без заголовка User-Agent, поэтому задавайте его явно. GitHub также советует проверять, кем является пользователь, при каждом получении нового токена, так как на странице авторизации пользователь мог переключиться на другой аккаунт.
Запрашивайте минимально необходимые scopes
Для входа через GitHub нужны только два OAuth-scope: read:user и user:email. Каждый из них описан в справочнике GitHub по scopes, и пользователи видят все запрашиваемые scopes на экране согласия GitHub.
| Что нужно | Scope |
|---|---|
| Профиль: id, логин, имя, аватар | read:user |
| Адреса электронной почты | user:email |
| Содержимое репозиториев | repo (полный доступ, включая приватные репозитории) |
Запрашивая repo для входа, вы просите пользователей предоставить доступ на чтение и запись к их приватному коду, и многие на этом этапе откажутся продолжать. Если доступ к репозиториям понадобится для какой-то функции позже, запустите отдельный поток авторизации именно в тот момент. GitHub допускает не более десяти токенов на каждую комбинацию пользователя, приложения и scope и не более десяти новых токенов в час. Если зацикленный callback упрётся в часовой лимит, GitHub остановит процесс и покажет пользователю запрос на повторную авторизацию.
Обновление истекающих токенов
Чтобы обновить токен GitHub, отправьте POST-запрос с grant_type=refresh_token на token endpoint. Использованный refresh-токен становится недействительным вместе со старым токеном доступа, поэтому сохраните новую пару, прежде чем выполнять следующий запрос. При включённых истекающих токенах токены доступа истекают через восемь часов, а неиспользованный refresh-токен — через шесть месяцев. После изменения от 14 августа 2026 года новые приложения получают такое поведение по умолчанию. Существующие приложения могут включить его для отдельного входа, добавив scope offline_access (дополнительного запроса согласия это не вызывает), либо активировать в настройках приложения.
async function refreshGitHubToken(refreshToken) {
// No refresh token means a non-expiring token: nothing to refresh.
if (!refreshToken) return null;
const res = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: refreshToken,
}),
});
const data = await res.json();
if (data.error === 'bad_refresh_token') return { reauth: true };
if (data.error) throw new Error(data.error);
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + data.expires_in * 1000,
};
}
Изменить scopes при обновлении нельзя. Ошибка bad_refresh_token означает, что пользователю нужно войти заново. Не рассчитывайте, что refresh_token вообще вернётся: некоторые версии GitHub Enterprise Server игнорируют offline_access и выдают бессрочный токен.
Что выбрать: OAuth App или GitHub App?
Если нужен только вход через GitHub, достаточно OAuth-приложения, запрашивающего read:user user:email, хотя в целом GitHub рекомендует GitHub Apps вместо OAuth-приложений. Все различия описаны на странице сравнения GitHub.
| OAuth App | GitHub App | |
|---|---|---|
| Модель разрешений | Широкие scopes | Детальные (fine-grained) разрешения |
| Выбор репозиториев | Все репозитории, на которые распространяется scope | Пользователь сам выбирает репозитории |
| Срок жизни токена | Для новых приложений — 8 часов по умолчанию; для старых — долгоживущие, если не включено истечение | Короткоживущие |
| Рекомендация GitHub | Поддерживается | Предпочтительно |
Выбирайте GitHub App, если ваш продукт выполняет действия с репозиториями. Для идентификации пользователей достаточно описанного выше потока OAuth App.
Чек-лист по безопасности
- Передавайте
stateи PKCES256в каждом запросе и проверяйте оба значения в callback. - Передавайте одинаковый
redirect_uriв запросе авторизации и в запросе токена. - Отключите wildcard matching, если он не нужен.
- Храните client secret и токены GitHub на сервере. Браузеру отдавайте только собственную сессионную cookie.
- После обмена кода выполняйте перенаправление со страницы callback.
- Обрабатывайте ответ с токеном, в котором нет
refresh_token.
Вход через GitHub сводится к трём запросам: авторизация, обмен кода, получение данных пользователя. Безопасность же кроется в деталях вокруг них: state, хранящийся на сервере, PKCE verifier, секрет, который никогда не покидает бэкенд, и email, подтверждённость которого вы проверили. Следующий шаг — откройте настройки OAuth-приложения, добавьте callback URL для preview-окружения и 127.0.0.1, отключите wildcard matching и разверните три описанных выше маршрута.
Часто задаваемые вопросы
Почему token endpoint GitHub возвращает bad_verification_code?
GitHub возвращает bad_verification_code, если переданный код неверен, истёк или не совпадает с кодом, выданным в ответ на запрос авторизации. Обычные причины — код старше 10 минут или код, который уже был обменян, например после нажатия кнопки «Назад» или повторного запроса к callback. Восстановить такой код нельзя. Запустите процесс входа заново, чтобы получить новый.
Чем вызвана ошибка redirect_uri_mismatch в GitHub OAuth?
GitHub возвращает redirect_uri_mismatch, если переданный redirect_uri не совпадает ни с одним callback URL, зарегистрированным в OAuth-приложении. Передавайте зарегистрированный URL в точности или опустите параметр — тогда GitHub использует URL приложения по умолчанию. Передавайте одно и то же значение в запросе авторизации и в запросе токена. GitHub считает 127.0.0.1 и localhost разными хостами, поэтому регистрируйте тот, который использует ваш локальный сервер.
Отзывает ли выход из моего приложения токен GitHub пользователя?
Нет. Завершение вашей собственной сессии оставляет токен GitHub и авторизацию действительными. Чтобы отозвать один токен, вызовите DELETE /applications/{client_id}/token. Чтобы удалить все токены и авторизацию приложения для этого пользователя, вызовите DELETE /applications/{client_id}/grant. Оба endpoint используют Basic-аутентификацию с client_id и client_secret, а access_token передаётся в теле запроса. Пользователи также могут отозвать доступ в настройках своего аккаунта GitHub.
Может ли бессрочный OAuth-токен GitHub перестать работать?
Да. GitHub автоматически отзывает OAuth-токен, который не использовался в течение года. Токен также перестаёт работать, если пользователь отзывает авторизацию вашего приложения или если токен попадает в публичный репозиторий или gist. Если для одной комбинации пользователя, приложения и scope накапливается более десяти токенов, GitHub отзывает один из более старых. Когда сохранённый токен перестаёт работать, проведите пользователя через вход повторно.