Device authentication with PIN and biometrics
Device authentication with PIN and biometrics turns an enrolled device into a complete passwordless login: an app-level PIN or platform biometrics (Face ID, fingerprint), combined with the device's hardware-resident key, signs the user in with a single request and grants an AAL2 session — no password, no one-time code.
This builds on device binding, which covers the underlying strategy, enrollment as a second factor, and platform requirements. This page covers the first-factor mode.
Key properties:
- The PIN never leaves the device and is never stored anywhere — not on the device, not on the server. It unlocks a secret on the device; the server verifies a cryptographic proof derived from that secret.
- The server is the only place a PIN guess can be tested. A stolen device, a stolen backup, or a full database dump cannot be used to guess the PIN offline.
- Wrong PIN attempts are counted server-side. After
pin_max_attemptsconsecutive failures (default 5), the key is locked and its secret destroyed. - The secret is delivered to the device exactly once at enrollment, end-to-end encrypted (HPKE) — TLS-terminating intermediaries and request logs only ever see ciphertext.
Availability: Ory Network and Ory Enterprise License. Platform support matches device binding: iOS 14.0+ and Android SDK 24+, native apps only (API flows).
How it works
Three keys
A PIN-protected enrollment involves three distinct keys:
| Key | Purpose | Lifetime | Known to the server |
|---|---|---|---|
| Device signing key | Signs the login challenge — the possession factor | Persistent, hardware-resident | Public half only |
| Sealing key | Wraps the stored PIN secret on the device | Persistent, hardware-resident, local | Never |
| Transport key (X25519) | Receives the one-time pin_secret at enrollment (HPKE) | Ephemeral — destroyed after enrollment | Public half only |
User verification levels
Every key records a user_verification level at enrollment. It is fixed at enrollment and not negotiable at login:
user_verification | How the user is verified | First factor | Second factor (step-up) |
|---|---|---|---|
pin | App PIN, proven to the server per login | Yes | Yes (PIN proof required) |
platform | Platform biometrics gate the signing key | Yes¹ | Yes |
none | Not verified | No | Yes |
¹ On iOS, biometric-only first factor requires the ios_biometric_first_factor opt-in — see Configuration.
The PIN mechanism
At enrollment the server generates a random 32-byte pin_secret, stores it encrypted, and sends it to the device exactly once,
sealed to the enrollment's transport key. The device never stores it in the clear: it derives a key from the user's PIN
(Argon2id), encrypts the secret with it (unauthenticated AES-CTR), and wraps the result under a non-exportable hardware key.
At login the device reverses the wrapping with the entered PIN and proves possession of the secret with an HMAC over the login challenge. A wrong PIN yields 32 bytes of garbage — indistinguishable from the real secret on the device — so the resulting proof is wrong and the server counts the failure. Nothing on the device (or in a stolen backup) can test whether a PIN guess is correct.
Biometric keys need none of this machinery: the platform gates the signing key itself and shows the biometric prompt when the key signs.
Assurance level
A successful first-factor login records possession (the device signature) plus knowledge (PIN) or inherence (biometrics) and grants an AAL2 session in a single submission. This matches NIST SP 800-63B's multi-factor cryptographic authenticator model: the PIN acts as an activation secret whose verification failures the server rate-limits.
Configuration
selfservice:
methods:
deviceauthn:
enabled: true
config:
# Allow deviceauthn keys with user_verification "pin" or "platform"
# to act as a complete first factor. Default: false.
first_factor: true
# Consecutive wrong-PIN attempts before a key is locked and its
# secret destroyed. Default 5, hard ceiling 10.
pin_max_attempts: 5
# Allow iOS biometric ("platform") keys as the sole first factor.
# Default false: App Attest cannot prove that a Secure Enclave key is
# biometric-gated, so this is an explicit opt-in.
ios_biometric_first_factor: false
# Bind enrollments (and iOS logins) to your apps. Empty lists disable
# the check — configure both in production.
ios_app_ids:
- "TEAMID.com.example.app"
android_app_ids:
- "0123…ef" # lowercase-hex SHA-256 of your app signing certificate
first_factor— enables the first-factor login path and its UI nodes. Without it, all keys are step-up only.pin_max_attempts— server-side lockout limit. Values above 10 are clamped (NIST SP 800-63B ceiling).ios_biometric_first_factor— on Android, the attestation proves that aplatformkey requires user authentication; on iOS it cannot, so iOSplatformkeys are step-up only unless you opt in. PIN keys are unaffected.ios_app_ids/android_app_ids— allow-lists checked against the attestation. Use the Apple App ID (<TeamID>.<BundleID>) and the SHA-256 digest of the Android app signing certificate (package names are forgeable).- PIN length and complexity are client-side concerns — see Client implementation requirements.
On Ory Network all of these are available through the project configuration. Relaxed attestation for emulator testing is described in device binding and only takes effect in development environments.
Protocol reference
All flows are native (API) flows. The flow's UI contains a hidden deviceauthn_nonce node; its value is the base64 encoding of
the JSON {"nonce":"<base64 of 32 raw bytes>"}. Decode twice to obtain the raw nonce bytes. The nonce is single-use and bound to
the flow.
PIN enrollment
Runs in a settings flow under a privileged session.
-
Generate an ephemeral X25519 keypair — the transport key (
t_priv,t_pub). It must exist before attestation, because its public half is baked into the attestation challenge. -
Compute the attestation challenge:
challenge = SHA256(nonce ‖ t_pub)The raw 32-byte nonce concatenated with the raw 32-byte transport public key, in that order, hashed once. This binds the transport key to the attested device: an intermediary that swaps
t_pubinvalidates the attestation.warningDo not use the bare nonce as the challenge — that is the second-factor device-binding form. Using it here fails with
Unable to validate the key attestation: wrong challenge. -
Create and attest the device signing key with that challenge. For PIN keys, create the key without platform user-verification gating — the PIN is the gate. On iOS pass the digest as
clientDataHashtoattestKey; on Android pass it tosetAttestationChallenge. -
Complete the settings flow:
{"method": "deviceauthn","add": {"device_name": "My work phone","version": 1,"pin_protected": true,"transport_public_key": "<base64 of t_pub>","attestation_ios": "<base64 CBOR attestation>"}}Android submits
certificate_chain_android(the attestation chain, leaf first) instead ofattestation_ios.user_verificationmay be omitted:pin_protected: trueimplies"pin". -
The server verifies the attestation, recomputes the challenge from the nonce it issued and the submitted
transport_public_key, assigns the key'sclient_key_id, generates thepin_secret, stores it encrypted, and returns it — exactly once — HPKE-sealed in the response'scontinue_with:{"continue_with": [{"action": "show_pin_entry_ui","data": {"enc": "<base64 HPKE encapsulated key>","ciphertext": "<base64 HPKE-sealed pin_secret>"}}]}The HPKE parameters are fixed and non-negotiable: DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, AES-128-GCM, with
info = "ory/deviceauthn/pin-secret/v1"and theclient_key_idstring as AAD. -
client_key_idis the key's deterministic fingerprint — the lowercase-hex SHA-256 of the device public key in PKIX, ASN.1 DER (SubjectPublicKeyInfo) form. It is not included incontinue_with: derive it locally (trivial on Android) or read it from the updated flow'sdeviceauthn_removenode for the new key (see the platform guides). -
The client opens the sealed secret with
t_priv, captures the user's PIN, seals the secret per the client requirements, and destroys the transport keypair. The key is immediately usable (confirmed).
Biometric enrollment
Same settings flow, three differences: the challenge is the bare nonce; the signing key is created with platform
user-verification gating (setUserAuthenticationRequired on Android, .biometryCurrentSet access control on iOS); and the
payload declares "user_verification": "platform" with no pin_protected and no transport_public_key. There is no secret and
no continue_with. On Android the server cross-checks the declaration against the attestation; on iOS it is trusted at enrollment
(which is why first-factor use is opt-in there).
First-factor login
Requires first_factor: true. The client creates a native login flow and submits:
{
"method": "deviceauthn",
"client_key_id": "<the key's fingerprint>",
"signature": "<base64>",
"pin_proof": "<base64, PIN keys only>"
}
-
signature— the device key over the raw nonce. Android: an ASN.1/DER ECDSA signature over the SHA-256 of the nonce (Signature("SHA256withECDSA")fed the nonce bytes). iOS: the CBOR App Attest assertion fromgenerateAssertion(keyId, clientDataHash: nonce). -
pin_proof— PIN keys only:pin_proof = HMAC-SHA256(key: pin_secret,msg: "ory/deviceauthn/pin-proof/v1" ‖ client_key_id ‖ nonce)The domain string and
client_key_idas UTF-8 bytes, the nonce raw, concatenated without separators.
The server resolves the identity from client_key_id, verifies the signature, and — for PIN keys — verifies the proof. Success
grants an AAL2 session in this single submission.
Every rejection (unknown key, ineligible key, bad signature, wrong PIN, locked key) returns the same error, so enrollment status
can't be probed. The lockout counter moves only on a valid signature with a wrong proof — the combination that proves the physical
device made a wrong-PIN attempt. At the limit the key state becomes locked and the stored secret is destroyed. A correct proof
resets the counter.
Step-up with a PIN key
At AAL2 step-up, a PIN key must send pin_proof alongside the signature — the device signature alone does not bypass the PIN. A
pin_proof submitted for a non-PIN key is rejected as a downgrade attempt. Biometric and none keys step up with the signature
alone, as described in device binding.
Rotating the PIN secret
Re-issues a fresh pin_secret for an existing PIN key — the recovery path for a forgotten PIN or a locked key. The device signing
key is unchanged; no re-attestation happens. Runs in a settings flow under a privileged session and additionally requires proof of
possession of the enrolled key:
{
"method": "deviceauthn",
"rotate_secret": {
"client_key_id": "<the key's fingerprint>",
"transport_public_key": "<base64 of a fresh t_pub>",
"signature": "<base64>"
}
}
signaturecovers the challengenonce ‖ t_pub— the raw 64-byte concatenation of the settings-flow nonce and the fresh transport public key, not hashed by the caller. Android signs it withSHA256withECDSAas usual; iOS passes it asclientDataHashtogenerateAssertion. This binding ensures a session-level attacker cannot rotate the secret to a transport key they control.- Only PIN keys can be rotated.
- Effects: fresh secret (delivered via the same one-time
continue_with), failure counter reset,lockedstate cleared.
Client implementation requirements
The security of the PIN path depends on the client following this recipe exactly. Each rule exists to preserve one invariant: the server's rate-limited proof check is the only place a PIN guess can be tested.
Sealing the secret
After opening the one-time pin_secret:
- Derive a key from the PIN:
pinKey = Argon2id(PIN, salt)with a fresh random salt. - Inner layer: encrypt the secret with unauthenticated AES-CTR under
pinKey, with a fresh random IV. CTR is deliberate: a wrong PIN yields plausible garbage, never a locally detectable failure. - Outer layer: seal the result under a non-exportable hardware key created without user-verification gating — an AES-256-GCM Android Keystore key (StrongBox where available), or an iOS Secure Enclave P-256 key used via ECIES.
- Store the sealed blob together with the salt, KDF parameters, IV, a format version, and the key alias. On iOS use the Keychain
with
kSecAttrAccessibleWhenUnlockedThisDeviceOnly(neverUserDefaults), so uninstalling the app purges it. Android Keystore keys are purged on uninstall automatically. - Wipe the PIN,
pinKey,pin_secret, and the transport private key from memory.
Hard rules
- Hardware sealing key or refuse. If no StrongBox/TEE/Secure Enclave key is available, refuse PIN enrollment. Never fall back to a software key — the offline-guessing resistance rests entirely on this.
- No local PIN-correctness signal. Never wrap the secret in anything that reveals whether a PIN guess was right: no MAC or AEAD tag on the inner layer, no checksum, magic bytes, length or format marker, and no "did it decrypt sensibly" heuristics. Any such artifact is an offline PIN-testing oracle.
- Fresh salt and IV on every seal — at enrollment, on PIN change, and after secret rotation. Reusing either leaks the secret through CTR keystream reuse.
- Zeroize. Fresh PIN entry for every authentication; never cache the PIN,
pinKey, orpin_secret. Hold them in mutable byte buffers (ByteArray,[UInt8],Data) — never in immutable strings — and overwrite with zeros after use. - Require an unlocked device. Create the sealing key so it is usable only while the device is unlocked
(
setUnlockedDeviceRequired(true)on Android;kSecAttrAccessibleWhenUnlockedThisDeviceOnlyon iOS). - Separate keys. The signing key and the sealing key are distinct hardware keys; the transport key is ephemeral and destroyed after enrollment.
PIN policy
The server never sees the PIN, so PIN policy is enforced by your app, not by Ory: require at least 6 digits (recommended; 4 is the
absolute floor) and reject common values (repeated or sequential digits, 123456, birthdate shapes). Server-side lockout bounds
the damage of a weak PIN; it does not make weak PINs safe.
Argon2id calibration
Mobile hardware varies widely. Ship pre-tuned parameter tiers, benchmark once on first launch to pick a tier, and store the chosen parameters with the sealed blob so unsealing always uses the enrollment-time values. As a starting point: 64 MiB memory, 3 iterations, parallelism 4.
Error routing
| Symptom | Meaning | Action |
|---|---|---|
| Server rejects login; local unseal succeeded | Wrong PIN (or key locked) | Let the user retry; count local retries |
| Repeated rejections despite correct-looking PIN | Key locked server-side | Recover: fallback login + rotate secret |
| Outer unseal fails / sealing key or blob missing | Local state lost (reinstall) | Re-enroll the key |
Never present a failed outer unseal as "wrong PIN" — it is structural and retrying cannot fix it.
iOS guide
This guide implements PIN enrollment, first-factor login, PIN change, and secret rotation on iOS. It builds on the App Attest signing key from device binding and adds the transport, sealing, and PIN layers this page describes. Read it together with Client implementation requirements — the comments in the code below are normative and restate those rules inline.
Prerequisites
- The App Attest entitlement
com.apple.developer.devicecheck.appattest-environmentset toproductionin your app's entitlements. - A real device. App Attest and the Secure Enclave are unavailable in the simulator, so PIN enrollment cannot run there.
- iOS 14 or newer.
- HPKE for the one-time transport channel: use
HPKEfrom CryptoKit on iOS 17+, or the swift-crypto package on iOS 14–16. Do not useSecKeyECIES for transport — the wire contract fixes the HPKE suite to DHKEM(X25519, HKDF-SHA256) / HKDF-SHA256 / AES-128-GCM, whichSecKeycannot produce. - swift-sodium for Argon2id (
Sodium().pwHash). - CommonCrypto for the unauthenticated AES-CTR inner layer.
The Secure Enclave sealing key uses SecKey ECIES (SecKeyCreateEncryptedData) — this is separate from transport, and there it
is the correct API.
Reference implementation
This listing is one complete recipe: nonce decoding, the enrollment and rotation ceremony (transport key, attestation, sealed
secret), the PIN vault (Secure Enclave sealing key, Argon2id, AES-CTR, seal and unseal), the PIN proof, and first-factor login.
The SettingsFlow and UpdateLoginFlowWithDeviceAuthnMethod types are Ory Swift SDK models.
import CommonCrypto
import CryptoKit
import DeviceCheck
import Foundation
import Sodium
enum DeviceAuthnPinError: Error {
case attestationUnsupported
case secureEnclaveUnavailable // never fall back to software — refuse PIN enrollment
case kdfFailed
case localStateMissing // sealed blob or sealing key gone → re-enroll, not "wrong PIN"
}
/// Decodes the value of the flow's hidden `deviceauthn_nonce` UI node:
/// base64(JSON {"nonce": "<base64 of 32 raw bytes>"}) → raw nonce bytes.
func decodeNonce(nodeValue: String) -> Data? {
guard let json = Data(base64Encoded: nodeValue),
let obj = try? JSONSerialization.jsonObject(with: json) as? [String: String],
let nonceB64 = obj["nonce"]
else { return nil }
return Data(base64Encoded: nonceB64)
}
/// One PIN enrollment (or secret rotation) ceremony. Holds the ephemeral HPKE
/// transport keypair; create a fresh instance per ceremony and let it go out of
/// scope afterwards — the transport key must never be reused or persisted.
final class PinCeremony {
private let transportPrivateKey = Curve25519.KeyAgreement.PrivateKey()
private(set) var appAttestKeyId: String?
/// Raw 32 bytes for the `transport_public_key` payload field (base64-encode it).
var transportPublicKey: Data { transportPrivateKey.publicKey.rawRepresentation }
/// Creates and attests the device signing key for a PIN enrollment.
/// The challenge binds the transport key: SHA256(nonce ‖ t_pub) — NOT the
/// bare nonce (the bare nonce is the second-factor device-binding form).
func createPinAttestation(nonce: Data) async throws -> Data {
let service = DCAppAttestService.shared
guard service.isSupported else { throw DeviceAuthnPinError.attestationUnsupported }
let keyId = try await service.generateKey()
appAttestKeyId = keyId
let challenge = Data(SHA256.hash(data: nonce + transportPublicKey))
return try await service.attestKey(keyId, clientDataHash: challenge)
}
/// Signs the rotate-secret challenge with an existing key: the raw
/// concatenation nonce ‖ t_pub, NOT pre-hashed.
func signRotationChallenge(nonce: Data, appAttestKeyId: String) async throws -> Data {
try await DCAppAttestService.shared.generateAssertion(
appAttestKeyId, clientDataHash: nonce + transportPublicKey)
}
/// Opens the one-time sealed secret from the response's continue_with item
/// {"action": "show_pin_entry_ui", "data": {"enc", "ciphertext"}}.
/// Suite (fixed): DHKEM(X25519, HKDF-SHA256) / HKDF-SHA256 / AES-128-GCM.
/// AAD is the client_key_id string.
func openSealedSecret(enc: Data, ciphertext: Data, clientKeyId: String) throws -> Data {
var recipient = try HPKE.Recipient(
privateKey: transportPrivateKey,
ciphersuite: .Curve25519_SHA256_AES_GCM_128,
info: Data("ory/deviceauthn/pin-secret/v1".utf8),
encapsulatedKey: enc
)
return try recipient.open(ciphertext, authenticating: Data(clientKeyId.utf8))
}
}
/// Reads the new key's client_key_id from the updated settings flow: the
/// deviceauthn_remove node whose meta context has the newest created_at.
/// (client_key_id is the lowercase-hex SHA-256 of the device public key in
/// SubjectPublicKeyInfo DER form — the server derives it, and it is NOT part
/// of continue_with.)
func clientKeyId(fromUpdatedFlow flow: SettingsFlow) -> String? {
flow.ui.nodes
.filter { $0.group == "deviceauthn" && $0.attributes.name == "deviceauthn_remove" }
.compactMap { node -> (String, String)? in
guard let value = node.attributes.value,
let createdAt = node.meta.label?.context?["created_at"] as? String
else { return nil }
return (value, createdAt)
}
.max { $0.1 < $1.1 }?.0
}
/// The local artifacts persisted after sealing. Store in the Keychain with
/// kSecAttrAccessibleWhenUnlockedThisDeviceOnly — never in UserDefaults — so
/// deleting the app purges them. None of these are secret on their own.
struct PinArtifacts: Codable {
let version: Int // format version of this recipe
let clientKeyId: String
let appAttestKeyId: String
let salt: Data // Argon2id salt — fresh on EVERY seal
let iv: Data // AES-CTR IV — fresh on EVERY seal
let opsLimit: Int // Argon2id parameters chosen at enrollment
let memLimit: Int
let sealingKeyTag: String
let sealed: Data // ECIES(SE key, AES-CTR(pinKey, pin_secret))
}
enum PinVault {
/// Creates the Secure Enclave sealing key. Fails closed: if the Secure
/// Enclave is unavailable, PIN enrollment must be refused — never use a
/// software key. No .userPresence/.biometryCurrentSet flag: the PIN is the
/// gate, the key must not prompt.
static func createSealingKey(tag: String) throws -> SecKey {
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.privateKeyUsage],
nil
)!
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: Data(tag.utf8),
kSecAttrAccessControl as String: access,
],
]
var error: Unmanaged<CFError>?
guard let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
throw DeviceAuthnPinError.secureEnclaveUnavailable
}
return key
}
static func argon2id(pin: [UInt8], salt: [UInt8], opsLimit: Int, memLimit: Int) throws -> [UInt8] {
guard
let key = Sodium().pwHash.hash(
outputLength: 32, passwd: pin, salt: salt,
opsLimit: opsLimit, memLimit: memLimit, alg: .Argon2ID13)
else { throw DeviceAuthnPinError.kdfFailed }
return key
}
/// Unauthenticated AES-CTR — encrypt and decrypt are the same operation.
/// Deliberately no MAC, checksum, or format marker: a wrong PIN must yield
/// plausible garbage, never a locally detectable failure.
static func aesCtr(key: [UInt8], iv: [UInt8], data: [UInt8]) -> [UInt8] {
var cryptor: CCCryptorRef?
CCCryptorCreateWithMode(
CCOperation(kCCEncrypt), CCMode(kCCModeCTR), CCAlgorithm(kCCAlgorithmAES128),
CCPadding(ccNoPadding), iv, key, key.count, nil, 0, 0, 0, &cryptor)
defer { CCCryptorRelease(cryptor) }
var out = [UInt8](repeating: 0, count: data.count)
var moved = 0
CCCryptorUpdate(cryptor, data, data.count, &out, out.count, &moved)
return out
}
/// Seals pin_secret under the PIN. Generates a FRESH salt and IV — reusing
/// either across seals leaks the secret via CTR keystream reuse.
static func seal(
pinSecret: inout [UInt8], pin: inout [UInt8], sealingKey: SecKey,
clientKeyId: String, appAttestKeyId: String, sealingKeyTag: String,
opsLimit: Int, memLimit: Int
) throws -> PinArtifacts {
defer {
// Zeroize: the PIN and the secret must not outlive the ceremony.
for i in pinSecret.indices { pinSecret[i] = 0 }
for i in pin.indices { pin[i] = 0 }
}
var salt = [UInt8](repeating: 0, count: 16)
_ = SecRandomCopyBytes(kSecRandomDefault, salt.count, &salt)
var iv = [UInt8](repeating: 0, count: 16)
_ = SecRandomCopyBytes(kSecRandomDefault, iv.count, &iv)
var pinKey = try argon2id(pin: pin, salt: salt, opsLimit: opsLimit, memLimit: memLimit)
defer { for i in pinKey.indices { pinKey[i] = 0 } }
let inner = aesCtr(key: pinKey, iv: iv, data: pinSecret)
let publicKey = SecKeyCopyPublicKey(sealingKey)!
var error: Unmanaged<CFError>?
guard
let sealed = SecKeyCreateEncryptedData(
publicKey, .eciesEncryptionCofactorVariableIVX963SHA256AESGCM,
Data(inner) as CFData, &error) as Data?
else { throw DeviceAuthnPinError.secureEnclaveUnavailable }
return PinArtifacts(
version: 1, clientKeyId: clientKeyId, appAttestKeyId: appAttestKeyId,
salt: Data(salt), iv: Data(iv), opsLimit: opsLimit, memLimit: memLimit,
sealingKeyTag: sealingKeyTag, sealed: sealed)
}
/// Unseals with the entered PIN. ALWAYS returns 32 bytes: a wrong PIN
/// yields garbage that only the server can falsify. A failure here is
/// structural (missing key/blob) and must route to re-enrollment.
static func unseal(artifacts: PinArtifacts, pin: inout [UInt8], sealingKey: SecKey) throws -> [UInt8] {
defer { for i in pin.indices { pin[i] = 0 } }
var error: Unmanaged<CFError>?
guard
let inner = SecKeyCreateDecryptedData(
sealingKey, .eciesEncryptionCofactorVariableIVX963SHA256AESGCM,
artifacts.sealed as CFData, &error) as Data?
else { throw DeviceAuthnPinError.localStateMissing }
var pinKey = try argon2id(
pin: pin, salt: [UInt8](artifacts.salt),
opsLimit: artifacts.opsLimit, memLimit: artifacts.memLimit)
defer { for i in pinKey.indices { pinKey[i] = 0 } }
return aesCtr(key: pinKey, iv: [UInt8](artifacts.iv), data: [UInt8](inner))
}
}
/// pin_proof = HMAC-SHA256(pin_secret, "ory/deviceauthn/pin-proof/v1" ‖ client_key_id ‖ nonce).
func pinProof(pinSecret: [UInt8], clientKeyId: String, nonce: Data) -> Data {
var message = Data("ory/deviceauthn/pin-proof/v1".utf8)
message.append(Data(clientKeyId.utf8))
message.append(nonce)
return Data(HMAC<SHA256>.authenticationCode(for: message, using: SymmetricKey(data: pinSecret)))
}
/// First-factor login with a PIN key.
func loginWithPin(flowNonce: Data, pin: inout [UInt8], artifacts: PinArtifacts, sealingKey: SecKey)
async throws -> UpdateLoginFlowWithDeviceAuthnMethod
{
var pinSecret = try PinVault.unseal(artifacts: artifacts, pin: &pin, sealingKey: sealingKey)
defer { for i in pinSecret.indices { pinSecret[i] = 0 } }
let proof = pinProof(pinSecret: pinSecret, clientKeyId: artifacts.clientKeyId, nonce: flowNonce)
// The login signature covers the RAW nonce (no transport key at login).
let assertion = try await DCAppAttestService.shared.generateAssertion(
artifacts.appAttestKeyId, clientDataHash: flowNonce)
return UpdateLoginFlowWithDeviceAuthnMethod(
clientKeyId: artifacts.clientKeyId,
method: "deviceauthn",
pinProof: proof,
signature: assertion
)
}
To wire the enrollment ceremony end to end: decode the flow nonce with decodeNonce, create a PinCeremony,
createPinAttestation, submit the add payload with transport_public_key and attestation_ios (see
PIN enrollment), then openSealedSecret on the returned continue_with, capture the PIN, PinVault.seal, and
persist the PinArtifacts. Let the PinCeremony go out of scope so its transport key is destroyed.
Biometric keys
Biometric (platform) keys skip the PIN machinery entirely — no transport key, no sealed secret, no pin_proof. The Secure
Enclave gates the signing key itself and shows the Face ID or Touch ID prompt when the key signs. Three differences from the PIN
flow:
- The attestation challenge is the bare nonce, not
SHA256(nonce ‖ t_pub). Pass the raw nonce bytes straight toattestKeyasclientDataHash. - Create the signing key with
.biometryCurrentSetin its access control, and enroll it with"user_verification": "platform"and nopin_protectedortransport_public_key. - At login, submit only
client_key_idandsignature(the assertion over the bare nonce) — omitpin_proof.
For the App Attest generateKey / attestKey / generateAssertion scaffolding, reuse the OryApi helper from
device binding. The PIN listing above calls
DCAppAttestService directly only to keep the challenge computation visible. On iOS, biometric first-factor login also requires
the ios_biometric_first_factor opt-in — see Configuration.
Changing the PIN and rotating the secret
Changing the PIN is a purely local operation — no server call. Unseal the secret with the old PIN, then seal it again with the
new PIN. PinVault.seal generates a fresh salt and IV, so the whole stored blob changes, but the pin_secret, client_key_id,
and signing key are unchanged. The server never learns that the PIN changed.
var oldPin: [UInt8] = /* entered old PIN */
var pinSecret = try PinVault.unseal(artifacts: artifacts, pin: &oldPin, sealingKey: sealingKey)
defer { for i in pinSecret.indices { pinSecret[i] = 0 } }
var newPin: [UInt8] = /* entered new PIN */
let updated = try PinVault.seal(
pinSecret: &pinSecret, pin: &newPin, sealingKey: sealingKey,
clientKeyId: artifacts.clientKeyId, appAttestKeyId: artifacts.appAttestKeyId,
sealingKeyTag: artifacts.sealingKeyTag,
opsLimit: artifacts.opsLimit, memLimit: artifacts.memLimit)
// Persist `updated` in place of the old artifacts.
Rotating the secret needs the server. It is the recovery path for a forgotten PIN or a locked key: the server issues a fresh
pin_secret for the same signing key. Start a settings flow under a privileged session, then:
- Create a fresh
PinCeremony— a new ephemeral transport key. - Call
signRotationChallenge(nonce:appAttestKeyId:)with the flow nonce and the key's App Attest key id. It signs the rawnonce ‖ t_pubconcatenation, unhashed. - Submit the
rotate_secretpayload withclient_key_id, the freshtransport_public_key, and that signature (see Rotating the PIN secret). - Open the new secret from the response's
continue_withwithopenSealedSecret, exactly as at enrollment. - Capture the user's PIN — a new one if they forgot the old — and
PinVault.sealthe new secret with a fresh salt and IV, then replace the stored artifacts.
The signing key and its client_key_id never change; only the sealed secret does. Only PIN keys can be rotated.
Android guide
This guide implements PIN enrollment, first-factor login, PIN change, and secret rotation on Android. It builds on the hardware-attested signing key from device binding and adds the transport, sealing, and PIN layers this page describes. Read it together with Client implementation requirements — the comments in the code below are normative and restate those rules inline.
Prerequisites
- Android SDK 24 (Android 7.0) or newer. The signing and sealing keys use StrongBox where the device offers it (API 28+) and fall back to the TEE otherwise.
- HPKE for the one-time transport channel: BouncyCastle (
org.bouncycastle:bcprov-jdk18on). The suite is fixed to DHKEM(X25519, HKDF-SHA256) / HKDF-SHA256 / AES-128-GCM with theclient_key_idstring as AAD. Tink's hybrid encryption API cannot pass that AAD, so use BouncyCastle's HPKE directly — do not substitute Tink. - Argon2id for the PIN key derivation:
org.signal:argon2(used below) or a libsodium binding. - AndroidX Biometric (
androidx.biometric:biometric) only for the biometric path — the PIN path never prompts, so it does not need it.
Reference implementation
This listing is one complete recipe: nonce decoding, the enrollment and rotation ceremony (transport key, attestation challenge,
sealed secret), the PIN vault (Android Keystore sealing key, Argon2id, AES-CTR, seal and unseal), the client_key_id derivation,
the PIN proof, and the login signature. The PinCeremony is a fresh object per ceremony; the transport key never outlives it.
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyInfo
import android.security.keystore.KeyProperties
import android.security.keystore.StrongBoxUnavailableException
import android.util.Base64
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
import org.bouncycastle.crypto.hpke.HPKE
import org.bouncycastle.crypto.params.X25519PublicKeyParameters
import org.json.JSONObject
import org.signal.argon2.Argon2
import org.signal.argon2.MemoryCost
import org.signal.argon2.Type
import org.signal.argon2.Version
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.MessageDigest
import java.security.PrivateKey
import java.security.SecureRandom
import java.security.Signature
import java.security.cert.Certificate
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.Mac
import javax.crypto.SecretKey
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
object DeviceAuthnPin {
private const val PIN_PROOF_DOMAIN = "ory/deviceauthn/pin-proof/v1"
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
/// The flow's hidden deviceauthn_nonce node value:
/// base64(JSON {"nonce": "<base64 of 32 raw bytes>"}) → raw nonce bytes.
fun decodeNonce(nodeValue: String): ByteArray {
val json = String(Base64.decode(nodeValue, Base64.DEFAULT))
return Base64.decode(JSONObject(json).getString("nonce"), Base64.DEFAULT)
}
fun sha256(vararg parts: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").run {
parts.forEach { update(it) }
digest()
}
/// client_key_id is the key's deterministic fingerprint: the lowercase-hex
/// SHA-256 of the device public key in SubjectPublicKeyInfo DER form —
/// which is exactly PublicKey.getEncoded() on Android.
fun clientKeyId(signingKeyAlias: String): String =
sha256(keyStore.getCertificate(signingKeyAlias).publicKey.encoded)
.joinToString("") { "%02x".format(it) }
/// Creates the attested signing key for a PIN enrollment. The challenge
/// must be SHA256(nonce ‖ t_pub) — NOT the bare nonce (that is the
/// second-factor device-binding form). No setUserAuthenticationRequired:
/// the PIN is the gate, the key must sign without a platform prompt.
fun createPinSigningKey(alias: String, nonce: ByteArray, transportPublicKey: ByteArray): List<Certificate> {
val challenge = sha256(nonce, transportPublicKey)
val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore")
val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN).run {
setDigests(KeyProperties.DIGEST_SHA256)
setAttestationChallenge(challenge)
if (Build.VERSION.SDK_INT >= 28) setIsStrongBoxBacked(true)
build()
}
return try {
kpg.initialize(spec)
kpg.generateKeyPair()
keyStore.getCertificateChain(alias).toList()
} catch (e: StrongBoxUnavailableException) {
// TEE fallback is fine for the SIGNING key; software is not — the
// server rejects software attestations unless relaxed attestation
// is enabled for testing.
val teeSpec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN).run {
setDigests(KeyProperties.DIGEST_SHA256)
setAttestationChallenge(challenge)
build()
}
kpg.initialize(teeSpec)
kpg.generateKeyPair()
keyStore.getCertificateChain(alias).toList()
}
}
/// Signs a login or rotation challenge. Login: the raw nonce. Rotation:
/// the raw concatenation nonce ‖ t_pub (not pre-hashed).
fun sign(alias: String, challenge: ByteArray): ByteArray =
Signature.getInstance("SHA256withECDSA").run {
initSign(keyStore.getKey(alias, null) as PrivateKey)
update(challenge)
sign()
}
/// pin_proof = HMAC-SHA256(pin_secret, domain ‖ client_key_id ‖ nonce).
fun pinProof(pinSecret: ByteArray, clientKeyId: String, nonce: ByteArray): ByteArray =
Mac.getInstance("HmacSHA256").run {
init(SecretKeySpec(pinSecret, "HmacSHA256"))
update(PIN_PROOF_DOMAIN.toByteArray())
update(clientKeyId.toByteArray())
update(nonce)
doFinal()
}
}
/// One PIN enrollment (or secret rotation) ceremony: holds the ephemeral HPKE
/// transport keypair. Create a fresh instance per ceremony; never persist or
/// reuse the transport key.
class PinCeremony {
// Suite (fixed, non-negotiable): DHKEM(X25519, HKDF-SHA256) / HKDF-SHA256 / AES-128-GCM.
private val hpke = HPKE(HPKE.mode_base, HPKE.kem_X25519_SHA256, HPKE.kdf_HKDF_SHA256, HPKE.aead_AES_GCM128)
private val hpkeInfo = "ory/deviceauthn/pin-secret/v1".toByteArray()
private val transportKeyPair: AsymmetricCipherKeyPair = hpke.generatePrivateKey()
/// Raw 32 bytes for the transport_public_key payload field (base64-encode it).
val transportPublicKey: ByteArray =
(transportKeyPair.public as X25519PublicKeyParameters).encoded
/// Opens the one-time sealed secret from the response's continue_with item.
/// AAD is the client_key_id string.
fun openSealedSecret(enc: ByteArray, ciphertext: ByteArray, clientKeyId: String): ByteArray {
val ctx = hpke.setupBaseR(enc, transportKeyPair, hpkeInfo)
return ctx.open(clientKeyId.toByteArray(), ciphertext)
}
}
/// Local artifacts persisted after sealing. None are secret on their own.
/// Android Keystore keys (and app storage) are purged on uninstall.
data class PinArtifacts(
val version: Int, // format version of this recipe
val clientKeyId: String,
val signingKeyAlias: String,
val sealingKeyAlias: String,
val salt: ByteArray, // Argon2id salt — fresh on EVERY seal
val ctrIv: ByteArray, // AES-CTR IV — fresh on EVERY seal
val gcmIv: ByteArray, // outer-layer GCM IV
val memoryCostMiB: Int, // Argon2id parameters chosen at enrollment
val iterations: Int,
val sealed: ByteArray, // KeystoreGCM(AES-CTR(pinKey, pin_secret))
)
object PinVault {
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
private val random = SecureRandom()
/// Creates the sealing key: AES-256-GCM, StrongBox where available, TEE
/// otherwise. Fails closed: after generation the key is verified to be
/// hardware-backed (TEE or StrongBox); a software key is deleted and
/// enrollment refused. No setUserAuthenticationRequired (the PIN is the
/// gate); setUnlockedDeviceRequired so a locked stolen device cannot unseal.
fun createSealingKey(alias: String) {
fun spec(strongBox: Boolean) =
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT).run {
setBlockModes(KeyProperties.BLOCK_MODE_GCM)
setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
setKeySize(256)
setRandomizedEncryptionRequired(false) // we manage the IV in the artifacts
if (Build.VERSION.SDK_INT >= 28) {
setUnlockedDeviceRequired(true)
setIsStrongBoxBacked(strongBox)
}
build()
}
val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
try {
kg.init(spec(strongBox = true))
kg.generateKey()
} catch (e: StrongBoxUnavailableException) {
kg.init(spec(strongBox = false))
kg.generateKey()
}
requireHardwareBacked(alias)
}
/// Throws unless the key lives in a TEE or StrongBox. The sealing key has
/// no server-side attestation backstop — this local check is the only
/// enforcement of the "hardware or refuse" rule.
private fun requireHardwareBacked(alias: String) {
val key = keyStore.getKey(alias, null) as SecretKey
val factory = SecretKeyFactory.getInstance(key.algorithm, "AndroidKeyStore")
val info = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo
val hardwareBacked = if (Build.VERSION.SDK_INT >= 31) {
info.securityLevel == KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT ||
info.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX
} else {
@Suppress("DEPRECATION")
info.isInsideSecureHardware
}
if (!hardwareBacked) {
keyStore.deleteEntry(alias)
throw IllegalStateException("no hardware-backed keystore; refusing PIN enrollment")
}
}
private fun argon2id(pin: ByteArray, salt: ByteArray, memoryCostMiB: Int, iterations: Int): ByteArray =
Argon2.Builder(Version.V13)
.type(Type.Argon2id)
.memoryCost(MemoryCost.MiB(memoryCostMiB))
.parallelism(4)
.iterations(iterations)
.hashLength(32)
.build()
.hash(pin, salt)
.hash
/// Unauthenticated AES-CTR — encrypt and decrypt are the same operation.
/// Deliberately no MAC, checksum, or format marker: a wrong PIN must yield
/// plausible garbage, never a locally detectable failure.
private fun ctr(key: ByteArray, iv: ByteArray, data: ByteArray): ByteArray =
Cipher.getInstance("AES/CTR/NoPadding").run {
init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv))
doFinal(data)
}
/// Seals pin_secret under the PIN. Generates a FRESH salt and IV — reusing
/// either across seals leaks the secret via CTR keystream reuse. Zeroizes
/// the PIN, the derived key, and the secret before returning.
fun seal(
pinSecret: ByteArray, pin: ByteArray, clientKeyId: String,
signingKeyAlias: String, sealingKeyAlias: String,
memoryCostMiB: Int = 64, iterations: Int = 3,
): PinArtifacts {
val salt = ByteArray(16).also(random::nextBytes)
val ctrIv = ByteArray(16).also(random::nextBytes)
val gcmIv = ByteArray(12).also(random::nextBytes)
val pinKey = argon2id(pin, salt, memoryCostMiB, iterations)
try {
val inner = ctr(pinKey, ctrIv, pinSecret)
val sealingKey = keyStore.getKey(sealingKeyAlias, null) as SecretKey
val sealed = Cipher.getInstance("AES/GCM/NoPadding").run {
init(Cipher.ENCRYPT_MODE, sealingKey, GCMParameterSpec(128, gcmIv))
doFinal(inner)
}
return PinArtifacts(
version = 1, clientKeyId = clientKeyId,
signingKeyAlias = signingKeyAlias, sealingKeyAlias = sealingKeyAlias,
salt = salt, ctrIv = ctrIv, gcmIv = gcmIv,
memoryCostMiB = memoryCostMiB, iterations = iterations, sealed = sealed,
)
} finally {
pinKey.fill(0)
pinSecret.fill(0)
pin.fill(0)
}
}
/// Unseals with the entered PIN. ALWAYS returns 32 bytes: a wrong PIN
/// yields garbage that only the server can falsify. An exception here is
/// structural (missing key/blob) and must route to re-enrollment — never
/// present it as "wrong PIN".
fun unseal(artifacts: PinArtifacts, pin: ByteArray): ByteArray {
try {
val sealingKey = keyStore.getKey(artifacts.sealingKeyAlias, null) as SecretKey
val inner = Cipher.getInstance("AES/GCM/NoPadding").run {
init(Cipher.DECRYPT_MODE, sealingKey, GCMParameterSpec(128, artifacts.gcmIv))
doFinal(artifacts.sealed) // outer tag covers integrity of the blob, not the PIN
}
val pinKey = argon2id(pin, artifacts.salt, artifacts.memoryCostMiB, artifacts.iterations)
try {
return ctr(pinKey, artifacts.ctrIv, inner)
} finally {
pinKey.fill(0)
}
} finally {
pin.fill(0)
}
}
}
Biometric keys
Biometric (platform) keys skip the PIN machinery entirely — no transport key, no sealed secret, no pin_proof. Android Keystore
gates the signing key itself: create it with setUserAuthenticationRequired(true) and sign through a BiometricPrompt, which
shows the fingerprint or face prompt when the key signs. Three differences from the PIN flow:
- The attestation challenge is the bare nonce, not
SHA256(nonce ‖ t_pub). Pass the raw nonce bytes straight tosetAttestationChallenge. - Create the signing key with
setUserAuthenticationRequired(true)and enroll it with"user_verification": "platform"and nopin_protectedortransport_public_key. The server cross-checks that declaration against the attestation, so aplatformkey really is biometric-gated — which is why Android biometric keys can be a first factor without an opt-in (see Biometric enrollment). - At login, submit only
client_key_idandsignature— theBiometricPromptassertion over the bare nonce — and omitpin_proof.
For the key-creation and BiometricPrompt signing scaffolding (the createKeyPair and launchBiometricSigner helpers), reuse
the OryApi from device binding. The PIN
listing above creates the signing key without setUserAuthenticationRequired precisely because the PIN — not a platform
prompt — is its gate.
Changing the PIN and rotating the secret
Changing the PIN is purely local — no server call. Unseal the secret with the old PIN, then PinVault.seal it again with the
new PIN. seal generates a fresh salt and IV, so the whole stored blob changes, while the pin_secret, client_key_id, and
signing key stay the same. The server never learns that the PIN changed.
val oldSecret = PinVault.unseal(artifacts, oldPin) // oldPin is zeroized by unseal
val updated = PinVault.seal(
pinSecret = oldSecret, pin = newPin, clientKeyId = artifacts.clientKeyId,
signingKeyAlias = artifacts.signingKeyAlias, sealingKeyAlias = artifacts.sealingKeyAlias,
memoryCostMiB = artifacts.memoryCostMiB, iterations = artifacts.iterations,
) // seal zeroizes oldSecret and newPin; persist `updated` in place of the old artifacts.
Rotating the secret needs the server. It is the recovery path for a forgotten PIN or a locked key: the server issues a fresh
pin_secret for the same signing key. Start a settings flow under a privileged session, then:
- Create a fresh
PinCeremony— a new ephemeral transport key. - Sign the rotation challenge with the enrolled key:
sign(alias, nonce + transportPublicKey)over the rawnonce ‖ t_pubconcatenation, not pre-hashed (contrast login, which signs the bare nonce). This binding stops a session-level attacker from rotating the secret to a transport key they control. - Submit the
rotate_secretpayload withclient_key_id, the freshtransport_public_key, and that signature (see Rotating the PIN secret). - Open the new secret from the response's
continue_withwithopenSealedSecret, exactly as at enrollment. - Capture the user's PIN — a new one if they forgot the old — and
PinVault.sealthe new secret with a fresh salt and IV, then replace the stored artifacts.
The signing key and its client_key_id never change; only the sealed secret does. Only PIN keys can be rotated.
Putting it together
Each ceremony maps to one flow in the Protocol reference; the JSON request and response bodies live there,
linked below. The code above zeroizes every PIN, derived key, and secret in a finally block and never persists the transport key
— keep that discipline when you wire these calls.
- Enroll (PIN enrollment) —
decodeNoncethe flow'sdeviceauthn_noncenode,createSealingKey, create aPinCeremony, thencreatePinSigningKey(alias, nonce, transportPublicKey)and submit theaddpayload withtransport_public_keyand the returned chain ascertificate_chain_android. On the response,openSealedSecreton thecontinue_withitem, derive the fingerprint withclientKeyId(alias), capture the PIN,PinVault.seal, and persist thePinArtifacts. Let thePinCeremonygo out of scope so its transport key is destroyed. - Log in (First-factor login) —
decodeNonce,PinVault.unsealwith the entered PIN,pinProof(pinSecret, clientKeyId, nonce), andsign(alias, nonce)over the raw nonce, then submitclient_key_id,signature, andpin_proof. - Rotate (Rotating the PIN secret) — a fresh
PinCeremony,sign(alias, nonce + transportPublicKey)over the unhashed concatenation, submit therotate_secretpayload, then open and re-seal exactly as at enrollment.
Testing in the emulator
The Android emulator has no StrongBox or TEE, so exercising the PIN flow on one needs two development-only relaxations — never in a release build.
First, the emulator produces software-backed attestations, which the server rejects by default. Enable relaxed attestation server-side to accept the signing key. Relaxed-attestation keys expire after 30 days, so re-enroll after that. See Relaxed attestation for testing.
Second, the sealing key also lands in the software keystore, so the reference createSealingKey refuses enrollment by design —
its requireHardwareBacked check throws. Temporarily bypass that check in a debug/test build to wire and flow-test the PIN path,
but never in a release build: the offline-guessing resistance rests entirely on a hardware sealing key. Verify the sealing path on
a physical device before shipping.
Recovery and lockout
| Situation | Path |
|---|---|
| User wants to change the PIN (knows it) | Client-only: unseal with the old PIN, re-seal with the new one (fresh salt and IV). No server call. |
| PIN forgotten, or sealed secret lost (reinstall) | Log in with another method, then rotate the secret. The device key and its attestation are kept. |
| Key locked after too many wrong PINs | Same: another login method, then rotate (clears the lock) — or delete and re-enroll the key. |
| Device lost or stolen | Delete the key from a session on another device, or via the admin identity APIs. |
A locked key is refused for both first-factor login and step-up. Locking destroys the stored secret, so a lock can never be silently undone — recovery always issues a fresh secret. Consider notifying users whenever a key is enrolled or its secret rotated, and delaying sensitive operations after either event.
Troubleshooting
Unable to validate the key attestation: wrong challengeat PIN enrollment — the attestation was created over the bare nonce. PIN enrollment binds the transport key: useSHA256(nonce ‖ transport_public_key)as the challenge. The bare nonce is correct only for second-factor device binding and biometric enrollment.The rotation signature is invalid.— the rotation signature must cover the raw concatenationnonce ‖ transport_public_key(64 bytes, not hashed by the caller, transport key generated fresh for this rotation).- Login always fails with the same generic error — by design the server returns one identical error for an unknown key, a bad
signature, a wrong PIN, and a locked key. Track wrong-PIN retries locally; after
pin_max_attemptsconsecutive failures assume the key is locked and offer recovery. - Keys enrolled before user verification existed — legacy keys cannot log in and must be re-enrolled.
- Relaxed-attestation keys stop working — keys enrolled with relaxed attestation expire after 30 days and are refused as soon as the setting is turned off. See relaxed attestation.
Security model
| Attacker has | Outcome |
|---|---|
| Stolen, locked device | The sealing key requires an unlocked device; the sealed secret is hardware-bound and PIN guesses can't be tested. |
| Exfiltrated backup or sealed blob | Opaque — the outer layer is sealed by a non-exportable hardware key that never leaves the original device. |
| Code execution on the unlocked device | Can unseal, but a wrong PIN yields garbage; the only oracle is the server, which locks the key after pin_max_attempts attempts (default 5). |
| Ory database dump (even with the encryption secrets) | The pin_secret — but login still requires the device's hardware key, and the PIN itself is not derivable. |
| TLS-terminating intermediary, request logs | Only HPKE ciphertext of the one-time secret delivery; the PIN never transits at all. |
Stated limitations:
- On iOS, biometric gating of a
platformkey cannot be proven by App Attest — it is trusted at enrollment. This is why iOS biometric-only first factor is an explicit opt-in (ios_biometric_first_factor). - PIN strength cannot be enforced server-side; the server never sees the PIN. Enforce policy in your app and rely on the lockout to bound weak-PIN damage.
- Locking a key destroys its secret. An attacker with deep device compromise can deliberately burn the attempt budget to force a recovery; recovery paths require a different login method.