OAuth через VK ID: подключение к Next.js приложению
VK ID — отдельная история. Это не классический OAuth провайдер первой свежести: за последние два года VK переделывал API дважды, а часть инструкций в интернете отстаёт от реальности. Я подключала актуальный VK ID OAuth 2.1 на двух Next.js-проектах в этом году. Расскажу пошагово.
Контекст: Next.js 14, App Router, серверные сессии (Better Auth или ручные — не суть). Если у тебя Pages Router или другой фреймворк — общая логика одинаковая, отличия только в синтаксисе обработчиков.
Что такое VK ID
VK ID — это OAuth 2.1 провайдер с поддержкой PKCE. Он живёт на id.vk.com и использует подход с одноразовым device_id вместо привычного state. Поддерживает Authorization Code Flow с PKCE, refresh-токены, выход через провайдера и пр.
Эндпоинты:
https://id.vk.com/authorize— экран входа с провайдером.https://id.vk.com/oauth2/auth— обмен кода на токен.https://id.vk.com/oauth2/user_info— данные пользователя.https://id.vk.com/oauth2/logout— выход через провайдера.
Регистрация приложения
Идёшь на id.vk.com и в кабинете разработчика создаёшь приложение. Несколько важных моментов:
- Тип приложения. «Веб-сайт».
- Базовый домен. Должен совпадать с твоим прод-доменом без http/https.
- Redirect URL. Точный путь, на который придёт callback. У меня
https://app.example.ru/auth/vk/callback. - Доверенный redirect URL. Параллельная настройка, нужна для серверного флоу. Без неё получишь ошибку «redirect_uri does not match».
На странице приложения VK выдаёт два значения: Application ID (число) и Service Token / Client Secret. Service Token нужен для серверных запросов от имени приложения. Для OAuth-callback мы будем использовать client_secret.
VK_CLIENT_ID=12345678
VK_CLIENT_SECRET=...
VK_REDIRECT_URI=https://app.example.ru/auth/vk/callbackPKCE и state
VK ID требует PKCE. Это значит, что мы:
- Генерируем случайный
code_verifier. - Считаем
code_challenge = base64url(sha256(code_verifier)). - Отправляем
code_challengeв authorize. - На callback присылаем
code_verifierв /token.
Сервер VK сравнивает: если challenge не сходится — отказ. Это защищает от перехвата code в редких сценариях со встроенными браузерами и редиректами.
Шаг 1: redirect на VK ID
Файл app/auth/vk/route.ts:
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import crypto from 'node:crypto';
function base64url(buffer: Buffer) {
return buffer.toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export async function GET() {
const codeVerifier = base64url(crypto.randomBytes(32));
const codeChallenge = base64url(
crypto.createHash('sha256').update(codeVerifier).digest()
);
const state = base64url(crypto.randomBytes(16));
cookies().set('vk_oauth_verifier', codeVerifier, {
httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600,
});
cookies().set('vk_oauth_state', state, {
httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600,
});
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.VK_CLIENT_ID!,
redirect_uri: process.env.VK_REDIRECT_URI!,
state,
code_challenge: codeChallenge,
code_challenge_method: 's256',
scope: 'email phone',
});
redirect(`https://id.vk.com/authorize?${params}`);
}Несколько моментов:
code_challenge_method— строгоs256, в нижнем регистре. Помню, как один раз поставилS256и ловил 400.scope— через пробел.emailвозвращает email,phone— номер телефона. Нужны отдельные модерации в кабинете VK.
Шаг 2: callback
Файл app/auth/vk/callback/route.ts:
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
export async function GET(req: Request) {
const url = new URL(req.url);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const deviceId = url.searchParams.get('device_id');
const error = url.searchParams.get('error');
if (error) {
return NextResponse.json({ error }, { status: 400 });
}
const cookieStore = cookies();
const expectedState = cookieStore.get('vk_oauth_state')?.value;
const verifier = cookieStore.get('vk_oauth_verifier')?.value;
if (!expectedState || expectedState !== state || !verifier || !code || !deviceId) {
return NextResponse.json({ error: 'state_mismatch' }, { status: 400 });
}
cookieStore.delete('vk_oauth_state');
cookieStore.delete('vk_oauth_verifier');
const tokenBody = new URLSearchParams({
grant_type: 'authorization_code',
code,
code_verifier: verifier,
client_id: process.env.VK_CLIENT_ID!,
client_secret: process.env.VK_CLIENT_SECRET!,
redirect_uri: process.env.VK_REDIRECT_URI!,
device_id: deviceId,
state,
});
const tokenRes = await fetch('https://id.vk.com/oauth2/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenBody,
});
if (!tokenRes.ok) {
return NextResponse.json({ error: await tokenRes.text() }, { status: 500 });
}
const token = await tokenRes.json() as {
access_token: string;
refresh_token: string;
expires_in: number;
user_id: number;
state: string;
};
const infoRes = await fetch('https://id.vk.com/oauth2/user_info', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
access_token: token.access_token,
client_id: process.env.VK_CLIENT_ID!,
}),
});
if (!infoRes.ok) {
return NextResponse.json({ error: await infoRes.text() }, { status: 500 });
}
const data = await infoRes.json() as { user: VkUser };
const user = await upsertUserFromVk(data.user, token);
await createSession(user.id);
return NextResponse.redirect(new URL('/', req.url));
}
type VkUser = {
user_id: string;
first_name: string;
last_name: string;
email?: string;
phone?: string;
avatar?: string;
verified?: boolean;
};Шаг 3: upsert
async function upsertUserFromVk(info: VkUser, token: VkToken) {
const accountFields = {
providerId: 'vk',
accountId: info.user_id,
accessToken: token.access_token,
refreshToken: token.refresh_token,
accessTokenExpiresAt: new Date(Date.now() + token.expires_in * 1000),
};
const existing = await db.query.account.findFirst({
where: (a, { and, eq }) => and(eq(a.providerId, 'vk'), eq(a.accountId, info.user_id)),
});
if (existing) {
await db.update(account).set(accountFields).where(eq(account.id, existing.id));
return await db.query.user.findFirst({ where: eq(user.id, existing.userId) })!;
}
const userRow = await db.insert(user).values({
id: cuid(),
email: info.email ?? `vk-${info.user_id}@no-email.local`,
name: `${info.first_name} ${info.last_name}`.trim(),
image: info.avatar ?? null,
emailVerified: !!info.email,
}).returning().then(r => r[0]);
await db.insert(account).values({ id: cuid(), userId: userRow.id, ...accountFields });
return userRow;
}Маленький, но важный момент: VK возвращает email только если пользователь подтвердил его и в scope был email. Если email отсутствует, не падай — у меня в БД для таких случаев плейсхолдер с пометкой «no-email», и потом отдельный шаг просит подтвердить настоящий адрес.
Refresh-токен
Access-токен у VK живёт час. Refresh-токен — год, но одноразовый: при использовании выдаётся новый refresh, старый перестаёт быть валидным. То есть нужно бережно хранить «последний выданный» refresh, иначе порвёшь цепочку.
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: storedRefresh,
client_id: process.env.VK_CLIENT_ID!,
device_id: storedDeviceId,
state: crypto.randomBytes(8).toString('hex'),
});Возвращается тот же набор: новые access и refresh, та же expires_in. Сразу обновляй обе записи в БД в одной транзакции — иначе при сбое окажешься без рабочего refresh.
Подводные камни
«invalid_grant: device_id required»
VK ID добавил device_id как обязательный параметр в обмен code на token и в refresh. На callback он приходит в query, не забудь его сохранить.
«invalid_request: code_verifier mismatch»
Что-то в кодах PKCE: либо ты считаешь base64 не url-safe (с +, /, =), либо неверный hash. Самое частое — забыл убрать padding =.
SameSite куки и iframe
Если у тебя VK ID one-tap встраивается через виджет, и ты ловишь куки в iframe — нужны SameSite=None; Secure. На обычном full-redirect SameSite=Lax хватает. Я предпочитаю full redirect, виджет лишних граблей подкидывает.
Email не пришёл
Половина пользователей VK не отдаёт email. Дизайн сценария регистрации должен это учитывать: либо просим email на втором шаге, либо разрешаем «вход без почты».
Через Better Auth
В Better Auth VK ID можно подключить через genericOAuth аналогично Яндексу. Главные кастомизации: PKCE включён по умолчанию, нужен ручной getUserInfo с POST-формой.
genericOAuth({
config: [{
providerId: 'vk',
clientId: process.env.VK_CLIENT_ID!,
clientSecret: process.env.VK_CLIENT_SECRET!,
authorizationUrl: 'https://id.vk.com/authorize',
tokenUrl: 'https://id.vk.com/oauth2/auth',
pkce: true,
scopes: ['email'],
getUserInfo: async (tokens) => {
const r = await fetch('https://id.vk.com/oauth2/user_info', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
access_token: tokens.accessToken,
client_id: process.env.VK_CLIENT_ID!,
}),
});
const j = await r.json();
const u = j.user;
return {
id: u.user_id,
email: u.email ?? `vk-${u.user_id}@no-email.local`,
emailVerified: !!u.email,
name: `${u.first_name} ${u.last_name}`.trim(),
image: u.avatar ?? null,
};
},
}],
})Параметр device_id сейчас нужно добавлять руками в callback handler. Проще всего — обернуть catch-all-роут middleware-ом, который пробрасывает device_id в контекст. Но это уже частный случай, тут зависит от версии Better Auth.
Вывод
VK ID — нормальный, рабочий OAuth, просто с парой нюансов: PKCE обязателен, требуется device_id, refresh-токен одноразовый. Эти три пункта закрывают 90% возможных проблем. Остальное — стандартная OAuth-механика, как и у Яндекса или Гугла.
На моих сервисах с момента подключения проблем не было: пользователи входят, сессия живёт, refresh обновляется в фоне. С учётом популярности VK среди российской аудитории — must-have в любом приложении, рассчитанном на массовый рынок.