· Jatinder Singh

Device-Bound Passkeys: Phishing Resistance Without the Sync

Before we get anywhere near the problem, it’s worth pausing on what passkeys quietly get right — there’s more of it than the debates suggest.

A passkey can’t be phished. Not “is hard to phish” — can’t be. The credential is locked to the website it was created for, so a pixel-perfect fake login page gets nothing: the browser simply won’t sign for the wrong domain. There’s no code to intercept, no password to trick out of anyone. After decades of telling users to “check the URL carefully,” we finally built something where checking the URL is the machine’s job. That’s phishing resistance, and it’s why every serious identity program is rolling out passkeys right now.

And the reason passkeys actually caught on — where smart cards and security keys stayed niche — is sync. When you create a passkey on your iPhone, Apple quietly tucks it into iCloud Keychain. Drop your phone in a lake, and your accounts come back with your new phone. Buy a tablet, and everything just works. Even NIST recognizes this benefit — 800-63B (§ 3.1.7.4) notes that syncing “eases the burden for subscribers who want to use additional devices to authenticate.” The WebAuthn spec — the W3C Web Authentication standard that passkeys are built on — goes further: it treats a credential getting backed up as a security upgrade, suggesting that when it happens, a site “MAY choose to prompt the user to upgrade their account security and remove their password” (§ 6.1.3). For most people, on most websites, synced passkeys beat passwords so completely that arguing about sync is missing the point.

So why is this article about turning sync off?

That question — real phishing resistance, minus the sync — is the one I set out to answer:

How do you use the passkey specification as written — same WebAuthn ceremonies, same phishing resistance — but with sync turned off? Not discouraged, not hidden behind policy: off, provably, in a way your identity platform can cryptographically verify before it accepts a single enrollment.

I want to keep the why and the how apart, because they’re different questions. Why an organization would turn off sync is situational, and I’ll make that case in the next section — you’re free to weigh it differently for your own deployment. But the how is an engineering problem with a definite answer, and that’s the story I’m here to tell.

Here’s what makes this newly possible: the WebAuthn Level 3 snapshot introduces the building blocks that let an authenticator declare a credential device-bound — signals earlier versions of the spec simply didn’t have. To actually test and understand how those pieces behave, I built a managed credential manager — device-bound by design — and ran it against a real identity platform on both Android and iOS. This post is what I learned: how the design works, and — just as important — the current, uneven state of device-bound passkeys across the two platforms.

TL;DR — Standards like NIST AAL3 rule out synced passkeys, but no mainstream credential manager will make you a non-syncing one — and a website can't request it, because the spec gives that choice to whoever builds the authenticator. So I built one: a company-managed credential manager app whose keys can never leave the phone, which proves its own identity cryptographically, with PingAM configured to enroll only its passkeys. It works end to end on Android today; on iOS, the platform currently won't accept a third-party app's device-bound passkey — it insists the credential claim it can be backed up, which a device-bound key isn't.

In this post:

  1. When sync becomes a liabilitythe why: the situations where a synced passkey is a risk, not a convenience
  2. What the spec actually gives youthe how begins: the BE/BS flags, AAGUID, and attestation, and the one thing the relying party can’t do
  3. The design: become the maker — a managed credential manager that turns every “you can’t” into “you decide”
  4. Building it: what iOS allows today — attestation stripped, backup flags forced: two findings from building it
  5. Building it: what Android allows today — the same authenticator, delivered to the server intact
  6. Building it: making the IdP enforce it — PingAM journeys that accept only CA-verified proof of origin
  7. Trade-offs and open decisions — attestation keys, hardware, and what recovery now costs you
  8. Closing thought

When sync becomes a liability

Here’s the uncomfortable flip side: everything that makes sync wonderful depends on the private key being able to leave your device. That’s what sync is. The key gets escrowed into a vendor’s cloud, copied to every device signed into the same account, restored through that account’s recovery flow — and in some ecosystems, shared with another person entirely.

For your personal email, that trade is great. But put yourself in the shoes of a bank, a government service, or a security team protecting admin accounts, and each of those conveniences reads differently:

  • The rules may simply say no. NIST 800-63B doesn’t hedge: “Since syncable authenticators … require the private key to be exportable, syncable authenticators SHALL NOT be used at AAL3.” If you need the highest assurance level, synced passkeys aren’t a risk to weigh — they’re off the table by definition.
  • You can’t see the part you’re trusting. With sync, the question is no longer “can someone phish my user?” It’s “can someone break into my user’s Apple or Google account recovery flow?” — a process you can’t watch, can’t harden, and can’t audit. The credential stopped being “something you have” and became “something your platform account has.”
  • Some credentials should never travel. Admin access, break-glass accounts, regulated workforce logins. For these, “it synced to the user’s personal cloud” isn’t a feature — it’s an incident report.

If none of these describe your deployment, synced passkeys are almost certainly your right answer — enjoy them. But customers in these situations keep asking the same, very reasonable question — can we keep the phishing resistance and lose the sync? — and for them, the rest of this post is the how: what the spec provides, a design that uses it, and what happened when I built it for real on iOS, Android, and a PingAM identity platform. The short version, if you want it up front: the spec has everything you need, Android delivers it, PingAM enforces it, and iOS just isn’t there yet.

What the spec actually gives you

A quick word on versions, because they matter here. The WebAuthn specification ships in numbered levels: Level 2 is the W3C Recommendation from April 2021 — a finished standard. Level 3 is the current version, published as a W3C Candidate Recommendation Snapshot in May 2026 — every quote in this post comes from that snapshot. “Snapshot” matters: Level 3 is still on its way to becoming a final Recommendation, so details can shift between publications. What I describe here is the spec as it stands today, not necessarily its final form. And the gap between those two dates is the whole story: Level 2 was written before ecosystem sync existed. The word “passkey” doesn’t appear in it once. It had no way to even say “this credential can be copied.”

Level 3 fixes that with two small flags that travel in every response the authenticator produces — once at registration, when the credential is born, and again in every login assertion after that, where the assertion signature covers them. Think of them like the stamp on a house key:

  • BE (Backup Eligible) — set once, when the credential is born, and frozen forever. It answers: can this key ever be copied? A credential with BE = 0 is like a key cut on a blank stamped “DO NOT DUPLICATE.” The spec is strict about it: “The BE flag SHALL be set if and only if the credential is a multi-device credential. This value MUST NOT change after a registration ceremony” (§ 6.1). That “if and only if” cuts both ways: a credential that can sync must have the flag on, and one that can’t must have it off. The spec doesn’t leave the authenticator any room to fudge it — the flag simply has to tell the truth.
  • BS (Backup State) — the live status light: is a copy sitting in the cloud right now? It may flip over a credential’s lifetime, but only within BE = 1. One combination can never happen: BE = 0 with BS = 1 — a key that can never be copied can’t have a copy sitting in the cloud. In the spec’s own words: “This combination is not allowed” (§ 6.1.3, Table 2) — a server that sees it should reject the response.

Two more pieces complete the toolkit. The AAGUID — a 16-byte identifier of the authenticator’s make and model, like the first six digits of a credit card number: it names the issuer, never the individual. And attestation — the authenticator’s signed birth certificate, covering the flags, the AAGUID, and the fresh challenge all at once, chaining to a root certificate the relying party can trust. The spec is refreshingly honest about how these relate: “the AAGUID is not provably authentic without attestation” (§ 6). The flags state the fact, the AAGUID names the maker, attestation proves both.

Now, the part nobody tells you: the relying party cannot ask for any of this behavior. I went through every input in the Level 3 spec — creation options, authenticator selection, hints, extensions — and there is nothing a website or IdP can send to request a device-bound credential. The sync decision belongs entirely to the authenticator’s maker, and the big ecosystems have all decided the same way: Apple’s and Google’s password managers always mint syncable credentials. Now imagine your server enforces a simple rule: only accept passkeys with BE = 0. A user tries to enroll with their iPhone — rejected, because iCloud Keychain stamps BE = 1 on every passkey it makes. They try again, and again — rejected every time, for the same reason. Your rule works perfectly, and yet nobody can get in: the credential managers your users actually have will never produce a passkey that passes it.

So this is where a server-side approach stops: the spec gives you everything you need to recognize a device-bound passkey, but nothing that can make one exist.

But turn that around and it’s not a wall at all: the spec hands the sync decision to whoever builds the authenticator — and nothing says that can’t be you. If no credential manager will supply a device-bound passkey, build the managed credential manager.

One caveat before you take that on: a credential manager is a security component, not just another app — it holds and guards your users’ private keys, and it has to be built with the care that role demands. I’ll come back to what that means in practice; for now, just hold the weight of it.

The design: become the maker

So what does “build the managed credential manager” look like? The organization builds and distributes its own managed credential manager — an app registered with the phone’s operating system the same way 1Password or Dashlane would be, with one defining property: it cannot sync. No cloud escrow, no export, no sharing. Private keys are generated on-device and marked non-exportable. In my build, the app is called SD Credential Manager (SD for Sqoop Data, the company I work for), and it carries a fixed authenticator identity:

  • One AAGUID for the model (dcde89fe-…), so the IdP’s allowlist has exactly one entry: is this ours?
  • A packed attestation statement on every registration — signed with an attestation key whose certificate chains to a private root CA (SD cman Attestation Root CA), and carrying the AAGUID inside the signed certificate via the FIDO id-fido-gen-ce-aaguid extension, so the identity claim can’t be spoofed separately from the proof.
  • Honest flags: BE = 0, BS = 0 — the truthful description of keys that never leave the device.

Here’s the app itself, running on Android — a plain list of the passkeys it holds, and the switch that registers it with the operating system as a passkey provider:

The SD Passwords app on Android: an 'Enable as passkey provider' button and one saved passkey for webauthn.io
SD Credential Manager on Android — the managed credential manager the organization ships

One more design choice to name up front: everything here rides on the spec’s hybrid transport flow — another Level 3 addition (§ 5.8.4). The phone holding SD Credential Manager acts as a roaming authenticator: whatever device a ceremony starts on — a laptop browser, a work desktop — a QR code hands it to the phone, and a Bluetooth proximity check confirms the phone is physically nearby. That’s what lets one managed app on one managed device serve as the user’s authenticator everywhere, without the credential ever living anywhere else. You’ll see it in action in the demo.

All the pieces, in one picture:

User's phone — SD Credential Manager the managed authenticator app the organization ships Device-bound private key · BE = 0 · never leaves the phone Signs a packed attestation — proof of "made by our app" hybrid transport QR code + Bluetooth proximity check User's computer — browser Neo Bank web app (the relying party) where registration or sign-in starts; displays the QR code registration response flags (BE/BS) + AAGUID + attestation PingAM — the identity platform (the same journeys work in PingOne Advanced Identity Cloud) 1 · Verify the attestation signature 2 · Chain the certificate to the trusted root 3 · AAGUID Gate — allow only attestation type CA allow deny Passkey saved verified, device-bound, ours Enrollment rejected clear on-screen explanation Trust anchor: SD Attestation Root CA company-owned root — signs the app's cert; sits in PingAM's trust store

Look back at the spec section, and every “you can’t” has flipped to “you decide.” BE = 0 isn’t a trick; the spec’s biconditional requires a non-syncing authenticator to report it. The AAGUID “MUST be chosen by its maker” (§ 6) — and now the maker is you. And attestation turns the whole story from claimed to proven.

That’s the design. Then I built it, twice — and the two platforms told two very different stories.

Building it: what iOS allows today

I built the managed credential manager for iOS first — an AutoFill credential provider extension (Apple’s framework name for the integration), with hardware-backed keys marked “this device only,” never synchronizable. Then I ran controlled experiments against the same relying party, changing one variable at a time. Two findings.

Finding 1: a packed attestation didn’t reach the site. This one is from an earlier test, less instrumented than the flags capture below, so I’ll state only what I observed. I registered the same credential twice, changing only the attestation format and holding the flags at the value iOS accepts in both runs. With fmt: "none", registration completed at the relying party. With fmt: "packed" — a real signature and certificate chain — the response never arrived; the website’s create() call failed. I haven’t re-captured this at the syslog level, so I won’t claim how iOS handled it — only that the packed response didn’t make it through.

Finding 2: iOS won’t accept the honest backup flags. For my device-bound keys — hardware-bound, non-syncable — the honest flags are BE = 0, BS = 0. iOS rejects that value. The two states I captured:

Flags sent Outcome
BE = 0, BS = 0 (honest — the key can’t sync) The extension completes and saves the credential — then iOS rejects it at a hidden validation step, before the site sees anything. The real reason (AuthorizationError Code=14 "AuthData is missing a required flag.") appears only in device syslog; the website gets a generic failure, and the app is left holding an orphaned passkey.
BE = 1, BS = 1 (claims the credential is synced and backed up) Accepted.

So iOS rejects the truthful value and accepts one that claims the credential can be backed up. The spec’s one rule for these flags is that they tell the truth — and for a device-bound key on iOS, the truthful flags are the ones that don’t get in.

The rejection, step by step

Claims like these deserve receipts, so I captured them properly: two registrations, minutes apart, on the same iPhone 14 Pro (iOS 26.5.2), against the same site, with full device syslog streaming to a cabled Mac. One binary, one variable. The app has a debug toggle that selects only the flags byte — everything else (Secure Enclave key generation, Face ID, the attestation object, the credential ID) is the identical code path. Run 1 sent the compliant byte 0x5D (BE = 1, BS = 1); Run 2 sent the honest byte 0x45 (BE = 0, BS = 0) for keys that live in the Secure Enclave and provably cannot leave. In both runs the extension finished its work successfully — the difference shows up in what the platform did next. The log lines below are verbatim (trimmed to time + process for width):

Stage Run 1 — compliant (0x5D) Run 2 — device-bound (0x45)
Hidden validation step — iOS inspects the returned credential before the site sees anything 21:40:57.827 AuthenticationServicesUI: 'prepareToCompleteRegistrationRequest…' Transaction created → released 1.6 ms later with nothing in between: passed silently 21:44:33.871 AuthenticationServicesUI(PasswordManagerUI): Returned credential failed validation: AuthorizationError Code=14 "AuthData is missing a required flag."
Agent handoff — format is handle, credential, error, requestID Completing authorization: 0xd4ec55900 <ASCPlatformPublicKeyCredentialRegistration: 0xd4f07f070> (null) (null) — a real credential object Completing authorization: 0xd4ec55a40 (null) (null) (null) — credential nulled, and the Code=14 reason nulled with it
What Safari tells the site MobileSafari: Successfully completed authorization MobileSafari: … failed with error: Code=1000 "(null)" — surfacing to web code as a generic NotAllowedError
What’s left on the phone Passkey registered at the site, saved locally, visible to AutoFill — about 11 ms end to end An orphaned passkey: saved locally and indexed for AutoFill, but the site never received it

And here’s what the device-bound run looks like from the outside — the last two rows of that table, on screen. The site (webauthn.io, the public WebAuthn test service) shows exactly this: “The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.” Notice the ending — the message speculates the user denied it, when it was the platform all along. Meanwhile the app sits there convinced the registration succeeded:

Safari on webauthn.io showing the error: the request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission
What the site sees: a generic refusal — no mention of flags anywhere
The SD Passwords app listing the orphaned webauthn.io passkey that the site never received
What the phone keeps: the orphaned passkey, saved as if everything worked

One thing in that table stuck with me: iOS knew exactly why — and kept it to itself. The device-bound registration didn’t fail blindly. In the phone’s log, the validation line spells it out, plain English and all:

… Returned credential failed validation: … Code=14 "AuthData is missing a required flag."
   UserInfo={NSLocalizedFailureReason=AuthData is missing a required flag.}

iOS understood the failure precisely and even wrote out a human-readable reason. But that line lives in exactly one place — the phone’s internal diagnostic log, readable only with the phone cabled to a Mac and low-level logging switched on. My app’s completion call returned nothing useful, and the website got only the generic NotAllowedError. The one component that understood the failure was the one that never told anyone. If you’re building in this space, my honest advice: capture that log from day one — every other surface will just keep telling you “no.”

In fairness to Apple: this reads as a gap, not a bug. iOS isn’t broken; it’s opinionated. Apple models passkeys as private, synced consumer credentials — and attestation, which reveals a device’s make and model, is the kind of signal a consumer platform has reason to be cautious about. What’s missing is a path for the enterprise case, where device-bound, attested credentials are the honest behavior.

Building it: what Android allows today

Then I built the same authenticator on Android — a CredentialProviderService, same AAGUID, same attestation chain, same device-bound key policy. And the platform just… passed it through.

Nothing inspected, nothing rewritten, nothing stripped, no forced flags. The two settings that iOS made impossible are, on Android, two constants:

// cman-android WebAuthnService.kt — both are delivered to the RP as-is:
const val USE_PACKED_ATTESTATION = true   // fmt:"packed" + x5c reaches the RP
const val BACKUP_FLAGS = 0                 // BE=0, BS=0 — the truthful device-bound value

The byte-level contrast for the same credential says it all — this is the flags byte of the authenticator data as it arrives at the server:

iOS   (forced):   0x5D = UP | UV | BE | BS | AT     ← claims synced-and-backed-up
Android (honest): 0x45 = UP | UV |           AT     ← BE=0, BS=0: device-bound, as it truly is

Put simply: on Android, my managed credential manager gets to tell the whole truth. The flags say “this key never leaves the device,” the attestation proves who said it — and the server receives both, exactly as the spec intended. Everything Level 3 makes possible actually arrives.

Building it: making the IdP enforce it

Delivering proof is only half the design — something has to check it, and that’s where the identity platform comes in. The relying party in my build is PingAM (a ForgeOps deployment), and everything in this section carries over as-is to PingOne Advanced Identity Cloud (P1AIC) or any on-premise/managed PingAM deployment — the journeys, nodes, and scripts below are the same building blocks in all of them. This is where the design turns into policy: only passkeys minted by my managed credential manager may enroll, and everything else is rejected with a clear explanation.

When PingAM checks a registration, it doesn’t just say yes or no — it also decides how much proof came with it, and gives that a grade called the attestation type. This grade isn’t one of the node’s exit paths (those are simply Success or Failure) — it’s a value the node works out while verifying, then tucks into the journey’s transient state under webauthnAttestationType for later steps to read. There are four possible grades:

Type Meaning
NONE No attestation statement — nothing to verify
SELF Signed with the credential’s own key — no independent proof
BASIC Statement verified, but the certificate didn’t chain to a trusted root
CA Signature verified and chained to a root in the node’s trust store

One thing to keep separate: this grade is not the same as the attestation mode you pick on the node (NONE / INDIRECT / DIRECT / ENTERPRISE, and I use DIRECT below). The mode is what you ask the authenticator for; the type above is what you actually got back.

(If Ping journeys and scripting aren’t your world, skim ahead to “What a user experiences” — it shows what all this machinery adds up to.)

Only CA means “cryptographically ours.” Getting there took three configuration decisions on the WebAuthn Registration node, each discovered by getting it wrong first:

  1. attestationPreference: DIRECT. This is what makes the attestation arrive. Under INDIRECT, clients routinely downgrade to fmt: "none"; DIRECT requests the statement unmodified. (One side effect: with a trust store configured, built-in platform passkeys arriving with fmt: "none" are rejected at the node itself — “attestation claims to be in an unsupported format.”)
  2. A trust store containing my root. The node validates the leaf’s chain up to SD cman Attestation Root CA. I proved where trust is decided by breaking it: removing the trust-store alias flipped the same credential from CA to BASIC on the next registration.
  3. postponeDeviceProfileStorage: true. The node verifies but does not save the credential — a separate Device Storage node does, later. That gap is where policy runs before anything is persisted.

And here’s the insight that shapes the journey: the node verifies, but it does not enforce. A BASIC or even NONE result still counts as node “success.” Enforcement is a scripted decision node I added — the AAGUID Gate — sitting between verification and storage. Its decision logic is genuinely one line — var allowed = (attType === 'CA'); — read AM’s verdict, allow only CA, deny everything else. Everything else in the script exists for the user’s benefit, not the policy’s: it digs the AAGUID, attestation format, and statement summary out of the raw registration CBOR and stashes them in shared state, so the result screen can show real verification details instead of a bare yes/no. Below is the complete script, pulled from the running AM instance — I’ve added comments throughout to explain what each function does and the CBOR byte-reading tricks, but the logic is exactly what’s deployed. (It targets the legacy Rhino scripting engine, which explains some of its quirks — like wrapping values in String(...) because Rhino hands back Java strings.)

The full AAGUID Gate script (cmanPasskeyReg, scripted decision node):

/*
 * cmanPasskeyReg - AAGUID Gate (LEGACY engine, evaluatorVersion 1.0).
 *
 * SINGLE-PASS, no UI (transient state must survive to WebAuthn Device Storage).
 * STRICT ENFORCEMENT: allow ONLY when AM's verified attestation type is CA, i.e.
 * the packed attestation signature verified AND its cert chained to our trusted
 * root (cmantrust -> cmantrustca). NONE/SELF/BASIC are denied. The unauthenticated
 * AAGUID match is kept for display only (CA already implies our AAGUID is in the
 * SIGNED cert). Captures debug into shared state for the Verdict Panel.
 */
var fr = JavaImporter(org.forgerock.openam.auth.node.api.Action);
var EXPECTED = 'dcde89feffe64041b87478404f52942c';

// AM hands the raw registration to scripts as a quirky string:
// "<clientData>::<byte>,<byte>,<byte>,..." — the second half is the whole
// attestationObject as a CSV of *signed* Java bytes. This turns it back
// into an array of unsigned 0-255 values we can scan.
function parseBytes(webauthnData) {
  var parts = String(webauthnData).split('::');
  if (parts.length < 2) { throw 'unexpected webauthnData shape'; }
  var csv = parts[1].split(',');
  var bytes = [];
  for (var i = 0; i < csv.length; i++) { var v = parseInt(csv[i], 10); if (v < 0) { v += 256; } bytes.push(v); }
  return bytes;
}
// Naive "find this byte pattern" search — the legacy Rhino engine has no
// Buffer or TextDecoder, so everything below works by locating known CBOR
// markers in the raw bytes and reading relative to them.
function indexOfSeq(bytes, seq, from) {
  for (var i = (from || 0); i <= bytes.length - seq.length; i++) {
    var ok = true;
    for (var j = 0; j < seq.length; j++) { if (bytes[i + j] !== seq[j]) { ok = false; break; } }
    if (ok) { return i; }
  }
  return -1;
}
// Pull the 16-byte AAGUID out of authData. The search pattern is the CBOR
// text-string header 0x68 ("8 chars follow") + the map key "authData".
// The value after the key is a CBOR byte string whose header length varies:
//   0x58 = 1-byte length follows, 0x59 = 2-byte length, 0x40-0x57 = tiny
//   string with the length baked into the header byte itself.
// Once past the header, the AAGUID sits at a FIXED offset inside authData:
//   32 (rpIdHash) + 1 (flags) + 4 (signCount) = byte 37, for 16 bytes.
function aaguidFromBytes(bytes) {
  var idx = indexOfSeq(bytes, [104, 97, 117, 116, 104, 68, 97, 116, 97]); // 0x68 + "authData"
  if (idx < 0) { throw 'authData not found'; }
  var p = idx + 9, authStart;
  if (bytes[p] === 0x58) { authStart = p + 2; }
  else if (bytes[p] === 0x59) { authStart = p + 3; }
  else if (bytes[p] >= 0x40 && bytes[p] <= 0x57) { authStart = p + 1; }
  else { authStart = p + 2; }
  var aaguid = bytes.slice(authStart + 37, authStart + 37 + 16);
  var hex = '';
  for (var i = 0; i < aaguid.length; i++) { var h = (aaguid[i] & 0xff).toString(16); if (h.length < 2) { h = '0' + h; } hex += h; }
  return String(hex);
}
// Read the attestation format ("none", "packed", ...) for display.
// Pattern = CBOR header 0x63 ("3 chars") + "fmt"; the value must be a short
// CBOR text string (0x60-0x77 = length 0-23 baked into the header byte).
function fmtFromBytes(bytes) {
  var idx = indexOfSeq(bytes, [99, 102, 109, 116]); // 0x63 + "fmt"
  if (idx < 0) { return '(fmt not found)'; }
  var p = idx + 4;
  if (bytes[p] < 0x60 || bytes[p] > 0x77) { return '(fmt not a short string)'; }
  var len = bytes[p] - 0x60, s = '';
  for (var i = 0; i < len; i++) { s += String.fromCharCode(bytes[p + 1 + i]); }
  return String(s);
}
// Build a one-line human summary of the attestation statement — purely for
// the result screen. 0xa0 is CBOR for "empty map": the fmt:"none" case.
// alg is a CBOR negative int (0x26 = -7 = ES256; 0x39 0x01 0x00 = -257 = RS256),
// sig is a byte string (read its length from the header, as above), and x5c
// is a CBOR array (0x81-0x97 = 1-23 items) — absent x5c means self-attestation.
function attStmtSummary(bytes) {
  var idx = indexOfSeq(bytes, [103, 97, 116, 116, 83, 116, 109, 116]); // 0x67 + "attStmt"
  if (idx < 0) { return '(attStmt not found)'; }
  if (bytes[idx + 8] === 0xa0) { return 'empty map {} - no statement, nothing to verify'; }
  var out = [];
  var algIdx = indexOfSeq(bytes, [99, 97, 108, 103], idx); // "calg"
  if (algIdx >= 0) {
    var a = bytes[algIdx + 4];
    if (a === 0x26) { out.push('alg=ES256 (-7)'); }
    else if (a === 0x39 && bytes[algIdx + 5] === 1 && bytes[algIdx + 6] === 0) { out.push('alg=RS256 (-257)'); }
    else { out.push('alg=(cbor 0x' + a.toString(16) + ')'); }
  }
  var sigIdx = indexOfSeq(bytes, [99, 115, 105, 103], idx); // "csig"
  if (sigIdx >= 0) {
    var h = bytes[sigIdx + 4], slen = -1;
    if (h === 0x58) { slen = bytes[sigIdx + 5]; }
    else if (h === 0x59) { slen = bytes[sigIdx + 5] * 256 + bytes[sigIdx + 6]; }
    else if (h >= 0x40 && h <= 0x57) { slen = h - 0x40; }
    if (slen >= 0) { out.push('sig=' + slen + ' bytes'); }
  }
  var x5cIdx = indexOfSeq(bytes, [99, 120, 53, 99], idx); // "cx5c"
  if (x5cIdx >= 0) {
    var arr = bytes[x5cIdx + 4];
    if (arr >= 0x81 && arr <= 0x97) { out.push('x5c=' + (arr - 0x80) + ' cert(s)'); } else { out.push('x5c=present'); }
  } else { out.push('x5c=absent (self-attestation)'); }
  return out.length ? out.join(', ') : 'present (unparsed)';
}
// Hand-rolled base64: the scripting sandbox has no btoa and blocks the Java
// reflection tricks that could reach an encoder. Used only to stash a copy of
// the raw attestationObject in shared state for debugging (decode at cbor.me).
var B64CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
function b64FromBytes(bytes) {
  var s = '';
  for (var i = 0; i < bytes.length; i += 3) {
    var b0 = bytes[i], b1 = (i + 1 < bytes.length) ? bytes[i + 1] : -1, b2 = (i + 2 < bytes.length) ? bytes[i + 2] : -1;
    s += B64CHARS.charAt(b0 >> 2);
    s += B64CHARS.charAt(((b0 & 3) << 4) | (b1 < 0 ? 0 : (b1 >> 4)));
    s += (b1 < 0) ? '=' : B64CHARS.charAt(((b1 & 15) << 2) | (b2 < 0 ? 0 : (b2 >> 6)));
    s += (b2 < 0) ? '=' : B64CHARS.charAt(b2 & 63);
  }
  return String(s);
}

// ---- Main flow starts here ----
// attType is the one input that actually decides anything: AM's own verdict
// (NONE / SELF / BASIC / CA) after it verified the attestation signature and
// tried to chain the certificate to the trust store.
var got = '(unreadable)', matched = false;
var attType = '(unavailable)', attFmt = '(unavailable)', attStmt = '(unavailable)', attB64 = '(unavailable)';
try { attType = String(transientState.get('webauthnAttestationType')); } catch (e) { logger.error('cman/gate attType: ' + e); }
try {
  var bytes = parseBytes(transientState.get('webauthnData'));
  got = String(aaguidFromBytes(bytes));      // String(): legacy Rhino returns java.lang.String from toString(16)
  matched = (got === EXPECTED);
  attFmt = fmtFromBytes(bytes);
  attStmt = attStmtSummary(bytes);
  var b64 = b64FromBytes(bytes);
  if (b64.length > 1200) { b64 = b64.substring(0, 1200) + '...(truncated, ' + bytes.length + ' bytes total)'; }
  attB64 = b64;
} catch (e) { logger.error('cman/gate: ' + e); }

// STRICT: cryptographic provenance only.
var allowed = (attType === 'CA');

// Everything below is display material for the Verdict Panel. It goes in
// SHARED state (survives UI round-trips) — unlike the credential itself,
// which lives in transient state and dies on any interactive node.
sharedState.put('cmanAllowed', allowed ? 'true' : 'false');
sharedState.put('cmanAaguidMatch', matched ? 'true' : 'false');
sharedState.put('cmanAaguidSeen', got);
sharedState.put('cmanAttType', attType);
sharedState.put('cmanAttFmt', attFmt);
sharedState.put('cmanAttStmt', attStmt);
sharedState.put('cmanAttB64', attB64);
action = fr.Action.goTo(allowed ? 'allow' : 'deny').build();

The registration journey reads: authenticate the user → WebAuthn Registration (DIRECT, trust store, postponed storage) → AAGUID Gate → Device Storage → verdict screen. One structural rule dictated the shape: the postponed credential lives in transient state, which is wiped by any interactive round-trip — so the gate must decide silently, storage must happen next, and only then may a screen explain what happened.

What a user experiences

Scenario by scenario (the walkthrough below shows the actual screens):

  • They register with the managed credential manager, and everything checks out. The attestation verifies as CA and the passkey is saved.
  • They register with some other credential manager. The AAGUID doesn’t match, so nothing is saved.
  • Something presents the managed AAGUID but can’t back it up with attestation. Nothing is saved. This is the impostor case — the right identifier without the paperwork — and it can also surface real operational problems, like a stripped attestation or a misconfigured trust store.
  • They try a built-in passkey (iCloud Keychain, Windows Hello, and the like). These arrive with no attestation statement at all, so the registration node itself rejects them before the policy gate even runs.

The impostor case teaches the right lesson here: the AAGUID in the authenticator data is just sixteen bytes anyone could type. I show it on screen, but I never trust it. The copy worth trusting lives inside the signed certificate — that’s what CA proves.

A walkthrough of the demo

Here’s the whole thing running: the relying party in my demo is a fictional bank (“Neo Bank”), and I’m registering from a computer while SD Credential Manager lives on my phone — so the ceremony hops devices over the QR flow along the way.

Registering a device-bound passkey. The full ceremony takes about forty seconds, and the clip below shows it end to end. Here’s what to watch for as it plays: the journey starts with a familiar username-and-password step to establish who’s enrolling; the browser then offers to save the passkey into its own manager — the ecosystem’s preference showing up in person, and accepting it would mint the very credential this design exists to avoid — so instead the user picks another device and scans a QR code; the phone takes over, where SD Credential Manager generates the device-bound key and builds the packed attestation; and the journey ends on a result screen that shows its work — attestation type CA, format packed, the AAGUID, the verified chain — before landing on a profile whose acr claim tells any downstream application this session was established with a device-bound passkey.

That QR hand-off in the middle is the hybrid transport from the design section, seen live. The spec describes it as “a combination of (often separate) data-transport and proximity mechanisms,” which “supports, for example, authentication on a desktop computer using a smartphone” (§ 5.8.4) — and notice the proximity half: because the Bluetooth check has to succeed, the ceremony can’t be farmed out to a phone somewhere else in the world.

And when the policy says no, the user isn’t left staring at a spinner — each refusal explains itself. These two screens are the policy from the earlier sections, made visible.

The same clarity guards both doors. At registration, a passkey created by any other credential manager is refused, and the screen says why in plain language — it wasn’t created by SD Credential Manager — tells the user where to get the managed app, and offers a “Try again” that loops straight back into the journey. At sign-in, an authenticator that was never enrolled — a platform passkey, someone else’s credential manager — fails with an equally clear next step: use the passkey you registered in SD Credential Manager, or register one first.

The registration rejection screen for a passkey created by the wrong credential manager
Registration: wrong credential manager — nothing saved
The sign-in failure screen when no managed passkey can be verified for the account
Sign-in: no enrolled passkey — same clarity at login

And login on a good day? A single WebAuthn Authentication node — the demo’s authentication journey is one selection and one biometric, phone in hand:

All the checking of who made the credential happened at enrollment — which is the only place it can happen, since attestation is a registration-time event. Get the front door right and the daily door stays simple.

Trade-offs and open decisions

The attestation key is the crown jewel. My demo ships one attestation key embedded in the app — fine for proving the architecture, forgeable in production: extract it once and you can mint “proof” forever. How attestation keys are provisioned, protected, and revoked at scale is a design decision your organization will need to make — there are several ways to approach it, each with its own trade-offs, and the right one depends on your fleet, your risk tolerance, and your PKI.

BE = 0 says the key won’t leave; hardware makes sure it can’t. A flag is a promise. In my demo I chose hardware-backed keys over software keys for exactly this reason: generating the key inside the phone’s secure hardware, non-exportable, turns that promise into a physical fact. Where and how keys are stored is what the spec calls the credential storage modality (§ 6.2.2) — and the spec deliberately sets that floor low; even software keys are conformant. Hardware is a choice above the spec’s floor — and at AAL3, NIST requires “a hardware-protected, isolated environment” outright. One subtlety I learned along the way: the spec counts manual import/export as backup too. A truly device-bound credential manager gives up exportability, not just cloud sync.

Recovery is yours now. This is the real bill for turning off sync. The spec states it directly: “A single-device credential is not resilient to single device loss. Relying Parties SHOULD ensure that each user account has additional authenticators registered” (§ 6.1.3). Require a second enrolled credential before an account counts as fully set up, and make re-issuance run through the same attested enrollment as day one. A recovery path weaker than the front door is where designs like this fall apart.

Key takeaways

  • The passkey spec already supports device-bound passkeys: the BE/BS flags say whether a key can sync, the AAGUID says who made it, and attestation proves both.
  • A website or IdP can’t ask for a device-bound passkey — the authenticator’s maker decides. If nobody supplies one, build the managed credential manager — but know what you’re taking on: a credential manager is a vault for your users’ keys, a high-risk piece of software in its own right. The design demands serious security due diligence and fortifying before it earns that trust.
  • Flags without attestation are just claims. Only a verified certificate chain (attestation type CA) turns “device-bound” into proof your platform can enforce.
  • Platform support is uneven today: on Android, a third-party credential manager’s honest flags and packed attestation reach the relying party intact; on iOS, the platform won’t accept a device-bound (BE = 0) passkey from a third-party app — and in an earlier test, a packed attestation didn’t reach the site either.
  • Turning off sync means owning recovery: require a second enrolled credential from day one, and make re-issuance as phishing-resistant as first enrollment.

Closing thought

None of this argues against synced passkeys — for the open web, recoverability is the security feature, and the ecosystems were right to build it. But when the requirement says the key must provably never leave the device, the spec already hands you every piece — and the design in this post isn’t theoretical. I built it, verdict screens and all.

Passkeys didn’t stop being “something you have.” If you want yours device-bound — sync turned off, provably — you just have to be the one who makes them.

References

  • WebAuthn Level 3 (W3C Candidate Recommendation Snapshot) — the specification every quote in this post is taken from, including the BE/BS flags, AAGUID, attestation, and hybrid transport.
  • WebAuthn Level 2 (W3C Recommendation) — the previous version, used for the before/after comparison; it predates the device-bound signals entirely.
  • NIST SP 800-63B-4, Digital Identity Guidelines — the source for the AAL3 requirements and the rule that syncable authenticators cannot be used at that level.
  • webauthn.io — the public WebAuthn test site shown in the iOS and Android screenshots. You can reproduce the platform behavior here yourself.
  • PingOne Advanced Identity Cloud (Ping Identity) — the identity platform that enforces the enrollment policy in this post; the same WebAuthn journeys, nodes, and scripts run on its on-premise sibling, PingAM.

If this is a problem you’re working through in your own organization — or just curious about how it works — I’d love to hear from you. Reach me at info@sqoopdata.com; questions, pushback, and lessons from your own attempts are all welcome. And if you’d like to see the demo live, I’m happy to set up a private session and walk you through it end to end.

A note on process: the work, the design, the architecture, the build, and the findings in this post are mine. I used an LLM as a second set of eyes to review it and sharpen the wording.