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

Passkeys могут храниться двумя способами:
Главное отличие passkeys от привычных паролей и кодов в том, что здесь пользователь ничего не передаёт сайту — всё происходит автоматически.
Passkeys уже стали частью крупных экосистем, что подтверждает: технология перестала быть экспериментом и превратилась в новый стандарт авторизации. Что происходит прямо сейчас:
Иными словами, passkeys — это уже рабочий стандарт, которым пользуются миллионы людей по всему миру.
Регистрация остаётся без изменений: клиент вводит телефон или e‑mail и подтверждает его кодом. Сразу после этого магазин может предложить добавить passkey: «Хотите включить быстрый вход на этом устройстве?». Если пользователь соглашается — создаётся ключ, и дальше он входит за секунду без пароля и без повторных кодов. Чтобы мотивировать клиента согласиться, можно предложить небольшой бонус, например, промокод со скидкой на первую покупку.
На устройствах Apple всё завязано на iCloud Keychain. Если passkey сохранён на iPhone, он автоматически доступен и на Mac. Вход выглядит так: открыл сайт → Face ID/Touch ID → готово. При покупке нового iPhone достаточно войти в iCloud — все passkeys подтянутся.
На Android и в Chrome ключи синхронизируются через Google Password Manager. Пользователь регистрирует passkey на телефоне (отпечаток или Face Unlock). Если он залогинен в том же Google-аккаунте в Chrome на ПК, то вход сработает сразу — браузер подтянет ключ автоматически. Телефон в этом случае не нужен.
Если же это новый или чужой компьютер, сайт покажет QR-код. Его можно отсканировать телефоном и подтвердить вход отпечатком или Face Unlock. При смене телефона достаточно авторизоваться в Google‑аккаунте — passkey приедет вместе с ним.
Windows использует Hello. После добавления passkey вход выглядит как ввод PIN, скан лица или отпечатка. Работает везде одинаково, если используется одна учётная запись Microsoft. Новый компьютер — зашёл в учётку, и passkeys приехали вместе с ней.
Если у пользователя телефон под рукой, а вход нужен на ноутбуке — сайт показывает QR. Сканируешь его камерой → подтверждаешь Face ID/отпечатком → ноутбук входит под твоим аккаунтом.

Полезно дать возможность добавить несколько passkeys к одному аккаунту: телефон, ноутбук, устройство партнёра или даже физический ключ. В личном кабинете это может быть раздел «Ключи входа» со списком устройств и кнопками «Добавить» и «Удалить».
Сегодня большинство современных устройств и браузеров уже поддерживают passkeys, но иногда встречаются исключения. Кроме того, не все пользователи готовы сразу перейти на новый метод. Поэтому в системе должны оставаться запасные варианты: пароль, письмо или SMS.
Passkeys — это не только про безопасность. В e‑commerce они напрямую влияют на деньги: сокращают отказы на шаге авторизации, уменьшают расходы на инфраструктуру и поддержку, а ещё делают покупку быстрее и проще. Ниже разберём основные изменения и как измерять эффект.
Когда пользователь входит за секунду по Face ID или отпечатку:
Чтобы эффект был заметен в цифрах, смотри на такие показатели:
Для интернет‑магазина passkeys — это меньше барьеров для клиентов и меньше операционных расходов. Для владельца бизнеса это означает рост конверсии, экономию на коммуникациях и поддержку, а значит — прямое влияние на прибыль и убытки компании.
Чтобы показать, как всё устроено на практике, разберём минимальный проект на 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](http://localhost:3000).Обратите внимание, что данные сохраняются только в памяти процесса, поэтому после перезапуска сервера ключи придётся регистрировать заново.
На мой взгляд passkeys уже не просто модная фича, а практичный способ убрать лишние шаги там, где чаще всего возникают проблемы: повторный вход, новое устройство. Технология уже поддерживается всеми экосистемами и достаточно просто реализовывается на любом стеке.
Что важно: мы не отказываемся от текущих процессов. Телефон или e‑mail всё равно подтверждаются при регистрации; дальше ты просто даёшь пользователю возможность быстрого входа в один клик и, тем самым, экономишь ему время, а себе — деньги на SMS и поддержку.
Начни с мягкой коммуникации с пользователем: при входе в личный кабинет показывай ненавязчивое уведомление «Хотите входить быстрее: Face ID/отпечаток вместо пароля?» с кнопкой «Да, давай». Параллельно сделай короткую рассылку или пуш‑уведомление активным пользователям с тем же предложением и ссылкой на страницу добавления ключа.
На странице авторизации размести «Войти без пароля» заметно, но вторым по приоритету, например, сразу под основным способом. Когда доля авторизаций по passkey достигнет 30–40% и не будет негативных отзывов, подними кнопку на первое место, а SMS/пароль перенеси вниз. Так ты постепенно снизишь расходы на OTP, а пользователи уже привыкнут к новому способу.