+1 (415) 843-4662

Passkeys in Ionic apps: passwordless sign-in with Capacitor

Passwords are the last part of most Ionic apps nobody has touched since 2019, and they are now the part that costs the most: support tickets for resets, SMS OTP bills, and an ever-longer list of enterprise security questionnaires. Passkeys fix all three. By 2026 every platform an Ionic app ships to — iOS 26, Android 16, and modern browsers for your PWA build — supports passkeys natively with synced credentials, so a user who enrolls on their phone can sign in on their tablet without doing anything.

This tutorial adds passkey sign-in to an Ionic + Capacitor app end to end: the server ceremonies, the native client call, the fallback path for devices and accounts that are not ready, and the migration plan for an app that already has millions of password users. Examples are Ionic Angular and Node on the server; the client API is identical for React and Vue.

What a passkey actually is

A passkey is a WebAuthn credential — a key pair. The private key never leaves the platform keychain (iCloud Keychain, Google Password Manager, or a third-party manager); your server stores only the public key and a credential ID. Sign-in is a signature over a server-issued challenge, unlocked by Face ID, Touch ID, or the device biometric/PIN. There is nothing to phish, nothing to reuse, and nothing in your database worth stealing.

Two ceremonies matter:

  • Registration (attestation) — server issues creation options, device makes a key pair, server stores the public key
  • Authentication (assertion) — server issues a challenge, device signs it, server verifies against the stored public key

Everything else is plumbing.

1. Domain association files (do this first)

Passkeys are bound to a domain — the relying party ID. A Capacitor app running from capacitor://localhost has no domain of its own, so it must be associated with yours. This step has a propagation delay and is the single most common reason a passkey build "doesn't work", so do it on day one.

iOS — serve https://example.com/.well-known/apple-app-site-association (no file extension, Content-Type: application/json, no redirects):

{
  "webcredentials": {
    "apps": ["TEAMID.com.example.app"]
  }
}

Then add the associated domain in ios/App/App/App.entitlements:

<key>com.apple.developer.associated-domains</key>
<array>
  <string>webcredentials:example.com</string>
</array>

Android — serve https://example.com/.well-known/assetlinks.json:

[{
  "relation": ["delegate_permission/common.get_login_creds"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.example.app",
    "sha256_cert_fingerprints": ["AB:CD:...:EF"]
  }
}]

Include the fingerprint of every signing key you ship with: your upload key, the Play App Signing key, and any internal QA key. A missing Play fingerprint means passkeys work in your debug build and fail for real users — a mistake you find in production if you are not careful.

Verify both files with a plain curl from outside your VPN before you write any client code.

2. Server: registration options

Use a maintained WebAuthn library rather than hand-rolling CBOR parsing. @simplewebauthn/server is the common choice in Node.

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from '@simplewebauthn/server';

const rpID = 'example.com';
const rpName = 'Example';
const origins = [
  'https://example.com',            // web / PWA
  'https://app.example.com',
  'android:apk-key-hash:<base64url-sha256-of-signing-cert>',  // Android native
  // iOS native sends https://example.com as the origin
];

app.post('/auth/passkey/register/options', requireSession, async (req, res) => {
  const user = req.user;
  const existing = await db.credentials.findByUser(user.id);

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userName: user.email,
    userDisplayName: user.name,
    attestationType: 'none',
    excludeCredentials: existing.map((c) => ({ id: c.credentialId })),
    authenticatorSelection: {
      residentKey: 'required',        // discoverable: enables usernameless sign-in
      userVerification: 'preferred',
    },
  });

  await db.challenges.put(user.id, options.challenge, { ttlSeconds: 300 });
  res.json(options);
});

residentKey: 'required' is what makes the "just tap Sign in" flow possible later. attestationType: 'none' keeps you out of attestation-statement verification, which almost no consumer app needs.

3. Server: verify registration

app.post('/auth/passkey/register/verify', requireSession, async (req, res) => {
  const expectedChallenge = await db.challenges.take(req.user.id);
  if (!expectedChallenge) return res.status(400).json({ error: 'challenge_expired' });

  const { verified, registrationInfo } = await verifyRegistrationResponse({
    response: req.body,
    expectedChallenge,
    expectedOrigin: origins,
    expectedRPID: rpID,
  });
  if (!verified || !registrationInfo) return res.status(400).json({ error: 'not_verified' });

  const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
  await db.credentials.insert({
    userId: req.user.id,
    credentialId: credential.id,
    publicKey: credential.publicKey,
    counter: credential.counter,
    transports: req.body.response?.transports ?? [],
    deviceType: credentialDeviceType,        // 'singleDevice' | 'multiDevice'
    backedUp: credentialBackedUp,
    label: req.body.label ?? 'Passkey',
    createdAt: new Date(),
  });

  res.json({ ok: true });
});

Store backedUp. A credential that is not synced lives on exactly one device; if that is the user's only passkey you should keep nudging them to add a second one or keep a recovery method alive.

4. Client: the Capacitor plugin

On native you cannot use navigator.credentials directly — the WebView is not the associated domain. Use a plugin that bridges to ASAuthorizationPlatformPublicKeyCredentialProvider (iOS) and Credential Manager (Android); @capacitor-community/passkeys and @corbado/capacitor-passkeys both do this and take the same JSON the server produced. Wrap it so web and native share one call site:

import { Capacitor } from '@capacitor/core';
import { Passkeys } from '@capacitor-community/passkeys';
import {
  startRegistration,
  startAuthentication,
} from '@simplewebauthn/browser';

const isNative = Capacitor.isNativePlatform();

export async function createPasskey(options: PublicKeyCredentialCreationOptionsJSON) {
  return isNative
    ? Passkeys.createPasskey({ options })
    : startRegistration({ optionsJSON: options });
}

export async function getPasskey(
  options: PublicKeyCredentialRequestOptionsJSON,
  conditional = false,
) {
  return isNative
    ? Passkeys.authenticate({ options })
    : startAuthentication({ optionsJSON: options, useBrowserAutofill: conditional });
}

5. Enrolment inside the app

The best moment to offer a passkey is right after a successful password or OTP login, while the user is already proving who they are.

import { Component } from '@angular/core';
import { AlertController, LoadingController } from '@ionic/angular';
import { createPasskey } from '../auth/passkey';
import { api } from '../api';

@Component({ selector: 'app-passkey-prompt', templateUrl: './passkey-prompt.html' })
export class PasskeyPromptComponent {
  busy = false;

  constructor(private alerts: AlertController, private loading: LoadingController) {}

  async enroll() {
    this.busy = true;
    const spinner = await this.loading.create({ message: 'Setting up…' });
    await spinner.present();
    try {
      const options = await api.post('/auth/passkey/register/options');
      const attestation = await createPasskey(options);
      await api.post('/auth/passkey/register/verify', {
        ...attestation,
        label: await deviceLabel(),          // e.g. "Pixel 9" / "iPhone 17"
      });
      await this.toast('Passkey saved. Next time just use Face ID.');
    } catch (err: any) {
      // User cancelled the sheet — never treat this as an error state
      if (err?.code === 'CANCELLED' || err?.name === 'NotAllowedError') return;
      const alert = await this.alerts.create({
        header: 'Could not create a passkey',
        message: 'You can try again from Settings → Security.',
        buttons: ['OK'],
      });
      await alert.present();
    } finally {
      this.busy = false;
      await spinner.dismiss();
    }
  }
}

Two rules that show up in every review we do: cancellation is not failure (the system sheet is easy to dismiss by accident), and never block the app on enrolment — offer it once, remember the decline, and leave a permanent entry in Settings.

6. Sign-in

With discoverable credentials the user does not have to type anything:

async signInWithPasskey() {
  const options = await api.post('/auth/passkey/login/options', {}); // no username
  const assertion = await getPasskey(options);
  const { accessToken, refreshToken } = await api.post('/auth/passkey/login/verify', assertion);
  await this.session.store(accessToken, refreshToken);
  this.nav.navigateRoot('/home');
}

Server side, generateAuthenticationOptions({ rpID, userVerification: 'preferred' }) with no allowCredentials, then verifyAuthenticationResponse against the credential the device returns, and finally persist the new signature counter:

const { verified, authenticationInfo } = await verifyAuthenticationResponse({
  response: req.body,
  expectedChallenge,
  expectedOrigin: origins,
  expectedRPID: rpID,
  credential: {
    id: stored.credentialId,
    publicKey: stored.publicKey,
    counter: stored.counter,
    transports: stored.transports,
  },
});
if (!verified) return res.status(401).json({ error: 'bad_assertion' });
await db.credentials.updateCounter(stored.credentialId, authenticationInfo.newCounter);

If newCounter ever goes backwards on a single-device credential, treat it as a cloned-authenticator signal: revoke the credential and force a full re-authentication.

On the web/PWA build, add conditional UI so passkeys appear in the browser autofill dropdown:

<input name="username" autocomplete="username webauthn" />

…and call getPasskey(options, true) on page load. Guard it with PublicKeyCredential.isConditionalMediationAvailable().

7. Tokens after sign-in

A passkey authenticates; it does not store your session. Keep refresh tokens out of localStorage — on native use a Keychain/Keystore-backed store:

import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin';

await SecureStoragePlugin.set({ key: 'refresh_token', value: refreshToken });

Short-lived access tokens in memory, refresh token in secure storage, and a server-side revocation list keyed by credential ID so "remove this passkey" also kills the sessions it created.

8. Fallback and recovery

You cannot delete passwords on day one. The matrix we ship with:

SituationBehaviour
Platform authenticator unavailableHide the passkey button, show password/OTP
Passkey exists but user is on a new, unsynced deviceOffer password/OTP, then prompt to enroll a passkey on that device
User lost all devicesAccount recovery via verified email + step-up checks, then forced passkey enrolment
Enterprise/MDM device with sync disabledAllow security-key passkeys (authenticatorAttachment: 'cross-platform')

Detect availability before rendering the button:

const available = isNative
  ? (await Passkeys.isAvailable()).available
  : (window.PublicKeyCredential &&
     await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable());

9. Testing

  • iOS simulator — passkeys work, but the association file must be reachable; use a real HTTPS staging domain, not a localhost tunnel without TLS
  • Android emulator — sign in to a Google account on the emulator first, or Credential Manager has nowhere to store the credential
  • Automated — you cannot drive the system sheet from Playwright or Maestro. Use the Chrome DevTools Protocol virtual authenticator (WebAuthn.addVirtualAuthenticator) for the web build to cover your server ceremonies, and keep native passkey checks in a short manual smoke test
  • Do test: cancel the sheet, airplane mode mid-ceremony, second device sign-in, revoking a credential from Settings

10. Rolling it out

  1. Ship enrolment behind a feature flag to internal users; watch for association-file errors in logs
  2. Enable for 5% of post-login users; measure enrolment acceptance and passkey sign-in success rate
  3. Ramp to 100%; make passkey the default button on the sign-in screen with "Other ways to sign in" underneath
  4. Once passkey sign-ins pass ~60% of logins, stop offering SMS OTP to enrolled users — that is where the cost saving lands
  5. Only then consider password deprecation for accounts with two or more backed-up credentials

The instrumentation matters as much as the code: log ceremony failures with their error name and platform, because almost all of them turn out to be one misconfigured line in an association file.


Passkeys are a two-to-three week project for a typical Ionic app, most of it server work and rollout care rather than client code. We do this as a fixed scope engagement — association files, server ceremonies, plugin integration, fallback matrix, and the rollout plan. Get in touch if you want your sign-in screen to stop generating support tickets.