Passkeys вместо паролей: безопасный и быстрый вход

В каждом интернет‑магазине часто возникает ситуация: покупатель хочет сделать заказ, но заходит с нового устройства или у него слетела сессия. Его просят снова ввести пароль или код из SMS/почты. Код может не прийти, пароль забывается — и вкладка закрывается. Так магазины теряют настоящих покупателей, готовых оформить заказ.
Недавно я нашёл рабочее решение, которое уже используют крупные международные площадки (Amazon, eBay, Walmart, Target, PayPal, а также Shopify): passkeys. Это стандарт входа без пароля, построенный на технологиях WebAuthn и FIDO2. Если коротко:
- FIDO2 — набор стандартов, которые позволяют отказаться от паролей и использовать биометрию и криптографию.
- WebAuthn — веб‑API, с помощью которого сайт создаёт пару ключей: приватный остаётся на устройстве, публичный хранится у сервиса. При входе устройство (через Face ID/Touch ID/PIN) подписывает одноразовый запрос, а сайт проверяет подпись публичным ключом.
Для покупателя всё выглядит очень просто: телефон или e‑mail подтверждаются один раз при регистрации, а дальше вход занимает секунду — Face ID, отпечаток или PIN, без пароля и проверочных кодов.
Что это даёт магазину
- Меньше брошенных корзин — убираем лишние преграды при повторном входе: пароль или проверочный код.
- Меньше расходов — коды используются только на первичную проверку контакта; повторные входы проходят через Face ID или отпечаток.
- Выше безопасность — passkeys устойчивы к фишингу и атакам, ведь пароля просто нет.
Как это выглядит на практике
- Первая покупка / регистрация. После подтверждения контакта предлагаем «Войти по Face ID/отпечатку» и создаём passkey — пароль придумывать не нужно.
- Новое устройство / новый браузер. Вход без пароля: Face ID/отпечаток или подтверждение через QR со смартфона.
- Семья или несколько устройств. К аккаунту можно привязать 2–3 passkeys (телефон, ноутбук, устройство партнёра) — без пересылки кодов и лишних шагов.
Немного статистики
По данным исследований FIDO Alliance и Nok Nok (2024–2025):
- количество успешных авторизаций увеличивается на 10–20%;
- авторизация проходит в 2–17 раз быстрее;
- обращений «не могу войти» в поддержку становится на 65–99% меньше.
Passkeys поддерживаются во всех современных браузерах (Safari, Chrome, Edge, Firefox) и экосистемах Apple/Google/Microsoft. Всё это работает уже сегодня — давай разберём, как именно.
Что такое passkeys и как они работают
Passkey — это пара ключей: приватный остаётся на устройстве пользователя, публичный хранится у сайта. При входе сайт генерирует одноразовый вызов (challenge), устройство подписывает его приватным ключом, а сервер проверяет подпись публичным ключом. Звучит сложно, но для пользователя всё просто: открыл сайт, посмотрел в камеру или приложил палец — и ты в аккаунте, без пароля.

Device-bound vs Synced
Passkeys могут храниться двумя способами:
- Device-bound — ключ хранится только на конкретном устройстве (например, физический ключ безопасности или локальный passkey). При смене устройства его придётся создавать заново.
- Synced — ключи синхронизируются через облако: iCloud Keychain, Google Password Manager, учётная запись Microsoft + Windows Hello. Зарегистрировался на одном устройстве — входишь на всех с тем же аккаунтом (Apple ID / Google / Microsoft).
Почему безопаснее и удобнее паролей и 2FA
Главное отличие passkeys от привычных паролей и кодов в том, что здесь пользователь ничего не передаёт сайту — всё происходит автоматически.
- Phishing-resistant. Ключ нельзя ввести на фейковом сайте, он привязан к настоящему домену.
- Криптографическая защита. Приватный ключ никогда не покидает устройство, даже если используется синхронизация через облако — в этом случае в облако попадает только зашифрованная копия, доступная лишь владельцу.
- Лучше, чем пароли и коды из SMS. Атакующему нечего перехватить, потому что ключи не передаются по сети. Также работа passkeys не зависит от доставки SMS или писем, что делает вход более надёжным.
Поддержка passkeys в браузерах и платформах
Passkeys уже стали частью крупных экосистем, что подтверждает: технология перестала быть экспериментом и превратилась в новый стандарт авторизации. Что происходит прямо сейчас:
- Apple, Google, Microsoft встроили поддержку passkeys в свои системы и предлагают пользователям включить её по умолчанию.
- По данным FIDO Alliance и IT Pro количество созданных passkeys выросло на 550% за 2024 год — это самый быстрый рост среди методов аутентификации.
- По исследованиям WIRED 57% пользователей уже знакомы с passkeys и готовы использовать их, что говорит о высокой степени готовности рынка.
Иными словами, passkeys — это уже рабочий стандарт, которым пользуются миллионы людей по всему миру.
UX‑кейсы
Регистрация остаётся без изменений: клиент вводит телефон или e‑mail и подтверждает его кодом. Сразу после этого магазин может предложить добавить passkey: «Хотите включить быстрый вход на этом устройстве?». Если пользователь соглашается — создаётся ключ, и дальше он входит за секунду без пароля и без повторных кодов. Чтобы мотивировать клиента согласиться, можно предложить небольшой бонус, например, промокод со скидкой на первую покупку.
iPhone / iPad / Mac (iCloud + Face ID/Touch ID)
На устройствах Apple всё завязано на iCloud Keychain. Если passkey сохранён на iPhone, он автоматически доступен и на Mac. Вход выглядит так: открыл сайт → Face ID/Touch ID → готово. При покупке нового iPhone достаточно войти в iCloud — все passkeys подтянутся.
Android и Chrome (Google Password Manager)
На Android и в Chrome ключи синхронизируются через Google Password Manager. Пользователь регистрирует passkey на телефоне (отпечаток или Face Unlock). Если он залогинен в том же Google-аккаунте в Chrome на ПК, то вход сработает сразу — браузер подтянет ключ автоматически. Телефон в этом случае не нужен.
Если же это новый или чужой компьютер, сайт покажет QR-код. Его можно отсканировать телефоном и подтвердить вход отпечатком или Face Unlock. При смене телефона достаточно авторизоваться в Google‑аккаунте — passkey приедет вместе с ним.
Windows 11 (Windows Hello: PIN/Face/Fingerprint)
Windows использует Hello. После добавления passkey вход выглядит как ввод PIN, скан лица или отпечатка. Работает везде одинаково, если используется одна учётная запись Microsoft. Новый компьютер — зашёл в учётку, и passkeys приехали вместе с ней.
Кросс‑авторизация: QR + Bluetooth
Если у пользователя телефон под рукой, а вход нужен на ноутбуке — сайт показывает QR. Сканируешь его камерой → подтверждаешь Face ID/отпечатком → ноутбук входит под твоим аккаунтом.

Один аккаунт — несколько устройств
Полезно дать возможность добавить несколько passkeys к одному аккаунту: телефон, ноутбук, устройство партнёра или даже физический ключ. В личном кабинете это может быть раздел «Ключи входа» со списком устройств и кнопками «Добавить» и «Удалить».
Если passkeys недоступны
Сегодня большинство современных устройств и браузеров уже поддерживают passkeys, но иногда встречаются исключения. Кроме того, не все пользователи готовы сразу перейти на новый метод. Поэтому в системе должны оставаться запасные варианты: пароль, письмо или SMS.
Бизнес‑эффект для онлайн‑магазинов
Passkeys — это не только про безопасность. В e‑commerce они напрямую влияют на деньги: сокращают отказы на шаге авторизации, уменьшают расходы на инфраструктуру и поддержку, а ещё делают покупку быстрее и проще. Ниже разберём основные изменения и как измерять эффект.
Что меняется на практике
Когда пользователь входит за секунду по Face ID или отпечатку:
- корзины реже бросают на этапе авторизации;
- расходы на SMS и e‑mail‑OTP снижаются, так как повторные коды просто не нужны;
- в поддержку приходит меньше обращений «не приходит код», «забыл пароль», «сменил телефон»;
Какие метрики трекать
Чтобы эффект был заметен в цифрах, смотри на такие показатели:
- доля успешных входов (Auth Success Rate) по паролю и по passkeys;
- среднее время входа (Time to Login) — цель 1–2 секунды;
- количество пользователей, потерянных на шаге авторизации (до/после);
- конверсия в заказ после логина;
- расходы на SMS и звонки (до/после);
- объём тикетов в поддержку по логину и восстановлению доступа.
Для интернет‑магазина passkeys — это меньше барьеров для клиентов и меньше операционных расходов. Для владельца бизнеса это означает рост конверсии, экономию на коммуникациях и поддержку, а значит — прямое влияние на прибыль и убытки компании.
Реализация в проекте Nuxt
Чтобы показать, как всё устроено на практике, разберём минимальный проект на Nuxt 4. Я собрал рабочее демо: в статье мы посмотрим ключевые файлы, а полный проект можно будет скачать архивом и запустить локально.

Клиентская часть
// ./app/composables/usePasskey.ts
import {
startRegistration,
startAuthentication,
type PublicKeyCredentialCreationOptionsJSON,
type PublicKeyCredentialRequestOptionsJSON,
type RegistrationResponseJSON,
type AuthenticationResponseJSON
} from '@simplewebauthn/browser';
import type {PasskeyVerifyResponse} from "~~/types/passkey";
export const usePasskey = () => {
const registerPasskey = async (payload: { username: string }) => {
// 1. Получаем опции от сервера
const options = await $fetch<PublicKeyCredentialCreationOptionsJSON>(
'/api/passkey/register/options',
{
method: 'POST',
body: payload,
},
);
// 2. Запускаем WebAuthn регистрацию в браузере
const attestationResponse: RegistrationResponseJSON =
await startRegistration({ optionsJSON: options });
// 3. Отправляем результат обратно на сервер
const verify = await $fetch<PasskeyVerifyResponse>(
'/api/passkey/register/verify',
{
method: 'POST',
body: { username: payload.username, response: attestationResponse },
},
);
if (!verify.ok) {
throw new Error(verify.error ?? 'Verification failed');
}
return verify;
};
const loginPasskey = async () => {
// 1. Запросить у сервера challenge и опции
const options = await $fetch<PublicKeyCredentialRequestOptionsJSON>(
'/api/passkey/auth/options',
{ method: 'POST' },
);
// 2. Вызвать WebAuthn API
const assertionResponse: AuthenticationResponseJSON =
await startAuthentication({ optionsJSON: options });
// 3. Отправить результат на сервер для проверки
const verify = await $fetch<PasskeyVerifyResponse>(
'/api/passkey/auth/verify',
{
method: 'POST',
body: { response: assertionResponse },
},
);
if (!verify.ok) {
throw new Error(verify.error ?? 'Authentication failed');
}
return verify;
};
return {registerPasskey, loginPasskey};
};
Здесь описаны две функции: registerPasskey
и loginPasskey
. Первая отвечает за регистрацию нового ключа после того, как пользователь ввёл и подтвердил свой телефон или e‑mail. Вторая — за вход без пароля.
// ./app/components/Passkey/PasskeyAuth.vue
<script setup lang="ts">
import { ref } from 'vue';
import { usePasskey } from "~/composables/usePasskey";
import type { PasskeyVerifyResponse } from '~~/types/passkey';
const username = ref('');
const result = ref<string | null>(null);
const raw = ref<PasskeyVerifyResponse | null>(null);
const loading = ref(false);
const { registerPasskey, loginPasskey } = usePasskey();
const handleRegister = async () => {
try {
loading.value = true;
const res = await registerPasskey({ username: username.value });
result.value = `✅ Зарегистрирован passkey для ${username.value}`;
raw.value = res;
} catch (err: unknown) {
result.value = `❌ Ошибка регистрации: ${(err as Error).message}`;
raw.value = null;
} finally {
loading.value = false;
}
};
const handleLogin = async () => {
try {
loading.value = true;
const res = await loginPasskey();
result.value = `✅ Вход выполнен как ${res.username}`;
raw.value = res;
} catch (err: unknown) {
result.value = `❌ Ошибка входа: ${(err as Error).message}`;
raw.value = null;
} finally {
loading.value = false;
}
};
</script>
<template>
<div class="max-w-xl mx-auto mt-10 p-6 bg-white shadow-md rounded-lg">
<h1 class="text-xl font-bold text-center mb-6">🔑 Passkeys Demo (<a href="https://yupopov.ru" target="_blank" class="underline">yupopov.ru</a>)</h1>
<div class="mb-4">
<label for="username" class="block text-sm font-medium mb-1">Имя пользователя (для регистрации)</label>
<input
v-model="username"
id="username"
type="text"
placeholder="Введите имя"
class="w-full border border-gray-300 p-2 rounded focus:outline-none focus:ring focus:ring-blue-500"
/>
</div>
<div class="grid grid-cols-2 gap-6 mb-6">
<button
@click="handleRegister"
:disabled="!username || loading"
class="px-6 bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
Зарегистрировать Passkey
</button>
<button
@click="handleLogin"
:disabled="loading"
class="px-6 bg-green-600 text-white py-2 rounded hover:bg-green-700 disabled:opacity-50"
>
Войти с Passkey
</button>
</div>
<div v-if="result" class="text-center mb-4">
{{ result }}
</div>
<div v-if="raw" class="mt-4">
<h2 class="text-sm font-semibold mb-1">Ответ сервера:</h2>
<pre class="bg-gray-100 text-xs p-3 rounded overflow-x-auto">{{ raw }}</pre>
</div>
</div>
</template>
Простой компонент с полем для ввода username и двумя кнопками: «Зарегистрировать Passkey» и «Войти с Passkey». После действия компонент выводит результат и показывает «сырые» данные ответа от сервера.
Серверные эндпоинты
// ./server/api/passkey/register/options.post.ts
import { defineEventHandler, readBody, setCookie } from 'h3';
import { generateRegistrationOptions } from '@simplewebauthn/server';
import type { GenerateRegistrationOptionsOpts, PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/server';
import { useRuntimeConfig } from '#imports';
export default defineEventHandler(async (event): Promise<PublicKeyCredentialCreationOptionsJSON> => {
const { username } = await readBody<{ username?: string }>(event);
if (!username) {
throw createError({ statusCode: 400, statusMessage: 'username is required' });
}
const config = useRuntimeConfig(event);
const rpID = config.rpId;
const rpName = config.rpName;
const opts: GenerateRegistrationOptionsOpts = {
rpName,
rpID,
userName: username
};
const options = await generateRegistrationOptions(opts);
setCookie(event, 'webauthn_reg_challenge', options.challenge, {
httpOnly: true,
sameSite: 'lax',
secure: true,
path: '/',
maxAge: 5 * 60,
});
return options;
});
Генерирует опции для регистрации нового passkey и сохраняет challenge в cookie.
// ./server/api/passkey/register/verify.post.ts
import { defineEventHandler, readBody, getCookie, setCookie } from 'h3';
import { verifyRegistrationResponse } from '@simplewebauthn/server';
import type { VerifiedRegistrationResponse } from '@simplewebauthn/server';
import { useRuntimeConfig } from '#imports';
import type {RegistrationResponseJSON} from "@simplewebauthn/server";
import {fakeDb} from "~~/server/utils/db";
export default defineEventHandler(async (event) => {
const body = await readBody<{ username?: string; response?: RegistrationResponseJSON }>(event);
const { username, response } = body ?? {};
if (!username || !response) {
throw createError({ statusCode: 400, statusMessage: 'username и response обязательны' });
}
const expectedChallenge = getCookie(event, 'webauthn_reg_challenge');
if (!expectedChallenge) {
throw createError({ statusCode: 400, statusMessage: 'Отсутствует challenge (cookie)' });
}
const config = useRuntimeConfig(event);
const origin = config.origin;
const verification: VerifiedRegistrationResponse = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
});
setCookie(event, 'webauthn_reg_challenge', '', { path: '/', httpOnly: true, secure: true, maxAge: 0 });
if (!verification.verified || !verification.registrationInfo) {
throw createError({ statusCode: 400, statusMessage: 'Верификация регистрации не пройдена' });
}
const {
credential,
} = verification.registrationInfo;
fakeDb.add({
username,
credentialId: credential.id,
publicKey: credential.publicKey,
counter:credential.counter,
});
return {
ok: true,
...credential
};
});
Проверяет ответ браузера, верифицирует регистрацию и сохраняет credential во временное хранилище.
// ./server/api/passkey/auth/options.post.ts
import { defineEventHandler, setCookie } from 'h3';
import { generateAuthenticationOptions } from '@simplewebauthn/server';
import type { GenerateAuthenticationOptionsOpts, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/server';
export default defineEventHandler(async (event): Promise<PublicKeyCredentialRequestOptionsJSON> => {
const config = useRuntimeConfig(event);
const rpID = config.rpId;
const opts: GenerateAuthenticationOptionsOpts = {
rpID
};
const options = await generateAuthenticationOptions(opts);
setCookie(event, 'webauthn_auth_challenge', options.challenge, {
httpOnly: true,
sameSite: 'lax',
secure: true,
path: '/',
maxAge: 5 * 60,
});
return options;
});
Отдаёт браузеру challenge и параметры для входа.
// ./server/api/passkey/auth/verify.post.ts
import { defineEventHandler, readBody, getCookie, setCookie } from 'h3';
import {verifyAuthenticationResponse, type WebAuthnCredential} from '@simplewebauthn/server';
import type { AuthenticationResponseJSON } from '@simplewebauthn/browser';
import {fakeDb} from "~~/server/utils/db";
export default defineEventHandler(async (event) => {
const body = await readBody<{ response?: AuthenticationResponseJSON }>(event);
const { response } = body ?? {};
if (!response) {
throw createError({ statusCode: 400, statusMessage: 'response is required' });
}
const expectedChallenge = getCookie(event, 'webauthn_auth_challenge');
if (!expectedChallenge) {
throw createError({ statusCode: 400, statusMessage: 'Missing challenge (cookie)' });
}
const config = useRuntimeConfig(event);
const rpID = config.rpId;
const origin = config.origin;
const stored = fakeDb.getByCredentialId(response.id);
if (!stored) {
throw createError({ statusCode: 400, statusMessage: 'Unknown credentialId' });
}
const credential:WebAuthnCredential = {
id: stored.credentialId,
publicKey: stored.publicKey,
counter: stored.counter,
};
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential,
});
setCookie(event, 'webauthn_auth_challenge', '', { path: '/', httpOnly: true, secure: true, maxAge: 0 });
if (!verification.verified) {
throw createError({ statusCode: 400, statusMessage: 'Authentication failed' });
}
fakeDb.updateCounter(stored.credentialId, verification.authenticationInfo.newCounter);
return {
ok: true,
username: stored.username,
};
});
Принимает ответ, проверяет его и возвращает результат входа вместе с именем пользователя.
Дополнительные файлы
Пример минимальной конфигурации для локального запуска:
# .env
RP_NAME="My New Shop"
RP_ID="localhost"
ORIGIN="http://localhost:3000"
Описание базовых типов:
// ./types/passkey.ts
import type {Base64URLString} from "@simplewebauthn/server";
export type PasskeyVerifyResponse = {
ok: boolean;
username: string;
credentialId: Base64URLString;
publicKey: Uint8Array;
counter: number;
error?: string;
};
Пример утилиты для создания фейковой базы данных.
// ./server/utils/db.ts
import type {Base64URLString} from "@simplewebauthn/server";
type StoredCredential = {
username: string;
credentialId: Base64URLString;
publicKey: Uint8Array;
counter: number;
};
class FakeDb {
private credentials = new Map<string, StoredCredential>();
add(cred: { username: any; credentialId: string; publicKey: Uint8Array; counter: number }) {
this.credentials.set(cred.credentialId, cred);
}
getByCredentialId(id: string): StoredCredential | undefined {
return this.credentials.get(id);
}
updateCounter(credentialId: string, newCounter: number) {
const cred = this.credentials.get(credentialId);
if (cred) {
cred.counter = newCounter;
this.credentials.set(credentialId, cred);
}
}
}
export const fakeDb = new FakeDb();
Готовый архив
Если вам удобнее смотреть код в своей IDE, то можете скачать архив со всеми файлами проекта:
Как запустить
- Скачайте архив с проектом из статьи.
- Установите зависимости:
npm i
. - Запустите проект:
npm run dev
. - Перейдите в браузере по адресу
http://localhost:3000
. - ведите имя пользователя, нажмите «Зарегистрировать Passkey», а затем «Войти с Passkey».
Обратите внимание, что данные сохраняются только в памяти процесса, поэтому после перезапуска сервера ключи придётся регистрировать заново.
Заключение
На мой взгляд passkeys уже не просто модная фича, а практичный способ убрать лишние шаги там, где чаще всего возникают проблемы: повторный вход, новое устройство. Технология уже поддерживается всеми экосистемами и достаточно просто реализовывается на любом стеке.
Что важно: мы не отказываемся от текущих процессов. Телефон или e‑mail всё равно подтверждаются при регистрации; дальше ты просто даёшь пользователю возможность быстрого входа в один клик и, тем самым, экономишь ему время, а себе — деньги на SMS и поддержку.
План перехода
Начни с мягкой коммуникации с пользователем: при входе в личный кабинет показывай ненавязчивое уведомление «Хотите входить быстрее: Face ID/отпечаток вместо пароля?» с кнопкой «Да, давай». Параллельно сделай короткую рассылку или пуш‑уведомление активным пользователям с тем же предложением и ссылкой на страницу добавления ключа.
На странице авторизации размести «Войти без пароля» заметно, но вторым по приоритету, например, сразу под основным способом. Когда доля авторизаций по passkey достигнет 30–40% и не будет негативных отзывов, подними кнопку на первое место, а SMS/пароль перенеси вниз. Так ты постепенно снизишь расходы на OTP, а пользователи уже привыкнут к новому способу.