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

Дата публикации
Дата обновления
Мем: сверху Дрейк отворачивается от пароля и SMS-кода, снизу одобряет вход по Touch ID, Face ID и PIN.

В каждом интернет‑магазине часто возникает ситуация: покупатель хочет сделать заказ, но заходит с нового устройства или у него слетела сессия. Его просят снова ввести пароль или код из 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), устройство подписывает его приватным ключом, а сервер проверяет подпись публичным ключом. Звучит сложно, но для пользователя всё просто: открыл сайт, посмотрел в камеру или приложил палец — и ты в аккаунте, без пароля.

Схема работы 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/отпечатком → ноутбук входит под твоим аккаунтом.

UX-сценарии работы с passkey: создание ключа после регистрации, вход по Face ID/Touch ID и QR-авторизация с компьютера
Три типовых UX-сценария для passkey: добавление после регистрации, быстрый вход через Face ID/Touch ID и авторизация с компьютера по QR-коду.

Один аккаунт — несколько устройств

Полезно дать возможность добавить несколько 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. Я собрал рабочее демо: в статье мы посмотрим ключевые файлы, а полный проект можно будет скачать архивом и запустить локально.

Схема работы passkey в Nuxt: клиент–сервер, challenge в сессии, ключ и credential в базе.
Клиент (Vue/WebAuthn) получает options, подписывает challenge и отправляет attestation на сервер. Nitro сверяет challenge и записывает credentialId, publicKey и counter в БД.

Клиентская часть

// ./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, а пользователи уже привыкнут к новому способу.

  • #Passkeys
  • #WebAuthn
  • #FIDO2
  • #Passwordless
  • #Nuxt
  • #Ecommerce
  • #UX