Skip to main content

Device authentication on iOS

A notable difference with Android is that Apple's app attestation APIs require a network call to Apple's servers from a real device.

This means that the emulator cannot be used.

Since Device Binding only is supported on native devices (not in the browser), all corresponding API calls should be done using the endpoints for native apps, to avoid having to pass cookies around manually.

Prerequisites

The second-factor guide below runs on a real device (App Attest and the Secure Enclave are unavailable in the simulator, so device binding cannot run there) with iOS 14 or newer. The first-factor PIN path has the same base requirements and additionally needs:

  • The App Attest entitlement com.apple.developer.devicecheck.appattest-environment set to production in your app's entitlements.
  • HPKE for the one-time transport channel: use HPKE from CryptoKit on iOS 17+, or the swift-crypto package on iOS 14–16. Do not use SecKey ECIES for transport — the wire contract fixes the HPKE suite to DHKEM(X25519, HKDF-SHA256) / HKDF-SHA256 / AES-128-GCM, which SecKey cannot 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.

Second-factor device binding

  1. Ensure that the DeviceAuthn strategy is enabled in the Kratos configuration. This strategy implements the settings and login flow. This is done so:

    selfservice:
    methods:
    deviceauthn:
    enabled: true
  2. In XCode, add a permission so that the application is allowed to use FaceID. In Target settings > Info > Custom iOS Target Properties, add:

    • Key: Privacy - Face ID Usage Description
    • Type: String
    • Value: This app uses FaceID to authenticate signing operations.
  3. Implement a runtime check for the OS version. If is lower than the documented ones, Device Binding may not be used, and a fallback should be found, for example using passkeys.

  4. This guide covers the second-factor setup: the UI should only show existing Device Binding keys and related buttons (e.g. to add a key) if the user is currently logged in. This can be confirmed with a whoami call. For first-factor PIN or biometric login, see First factor with PIN.

  5. Create a settings flow for native apps. The response contains the list of existing Device Binding keys.

  6. To delete an existing key, complete the settings flow with this payload:

    {
    "delete": {
    "client_key_id": "9c62918cbbef2e94c3a10238bd57ab196e3a2caae1a44f28b02f2d1a72b1e59c"
    },
    "method": "deviceauthn"
    }

    Or using the SDK:

    let clientKeyId = "..."

    let flow = try await FrontendAPI.createNativeSettingsFlow(
    xSessionToken: sessionToken
    )

    let body: UpdateSettingsFlowBody =
    .typeUpdateSettingsFlowWithDeviceAuthnMethod(
    UpdateSettingsFlowWithDeviceAuthnMethod(
    delete: UpdateSettingsFlowWithDeviceAuthnMethodDelete(
    clientKeyId: clientKeyId,
    ),
    method: "deviceauthn"
    )
    )
    let finalFlow = try await FrontendAPI.updateSettingsFlow(
    flow: flow.id,
    updateSettingsFlowBody: body,
    xSessionToken: sessionToken
    )

    Once the key has been deleted server-side, it is fine (although not required) to also delete it on the device using the KeyStore API.

  7. To add a new key, complete the settings flow with this payload:

    {
    "method": "deviceauthn",
    "add": {
    "device_name": "iPhone (iPhone14,5)",
    "attestation_ios": "..."
    }
    }

    Or using the SDK:

    let flow = try await FrontendAPI.createNativeSettingsFlow(
    xSessionToken: sessionToken
    )

    let nonce = extractNonceFromUiNodes(nodes: flow.ui.nodes) ?? ""
    let deviceName = "My work phone"
    let (keyId, attestation) = try await OryApi().createKey(
    challengeB64: nonce
    )

    let body: UpdateSettingsFlowBody =
    .typeUpdateSettingsFlowWithDeviceAuthnMethod(
    UpdateSettingsFlowWithDeviceAuthnMethod(
    add: UpdateSettingsFlowWithDeviceAuthnMethodAdd(
    attestationIos: attestation,
    deviceName: deviceName,
    ),
    method: "deviceauthn"
    )
    )
    let finalFlow = try await FrontendAPI.updateSettingsFlow(
    flow: flow.id,
    updateSettingsFlowBody: body,
    xSessionToken: sessionToken
    )

    // The server assigns the key's client_key_id — the lowercase-hex SHA-256
    // fingerprint of the public key. Read it from the updated flow (the value
    // of the new key's `deviceauthn_remove` node) and store it together with
    // the App Attest keyId: signing uses keyId, API calls use clientKeyId.
    let clientKeyId = extractClientKeyIdFromUiNodes(nodes: finalFlow.ui.nodes)

    Once a key is created, the application must store both identifiers — the App Attest keyId (needed to sign) and the server-assigned client_key_id (needed to address the key in API calls) — because there are no APIs to list keys or check if a key exists. Note that there is a maximum number of keys that can be created for an identity, and there is no point to create multiple keys for the same user on the same device, even though the server allows it.

  8. To use a key to step-up the AAL, complete the login flow with this payload:

    {
    "signature": "...",
    "client_key_id": "9c62918cbbef2e94c3a10238bd57ab196e3a2caae1a44f28b02f2d1a72b1e59c",
    "method": "deviceauthn"
    }

    Or using the SDK:

    let keyId = "..." // The App Attest key id, used to sign.
    let clientKeyId = "..." // The server-assigned key fingerprint.

    let flow = try await FrontendAPI.createNativeLoginFlow(
    refresh: false,
    aal: AuthenticatorAssuranceLevel.aal2.rawValue,
    xSessionToken: sessionToken
    )
    let nonce = extractNonceFromUiNodes(nodes: flow.ui.nodes) ?? ""

    let signature = try await OryApi().signWithKey(
    keyId: keyId,
    challengeB64: nonce,
    )

    let body =
    UpdateLoginFlowBody
    .typeUpdateLoginFlowWithDeviceAuthnMethod(
    UpdateLoginFlowWithDeviceAuthnMethod(
    clientKeyId: clientKeyId,
    method: "deviceauthn",
    signature: signature,
    )
    )

    let finalFlow = try await FrontendAPI.updateLoginFlow(
    flow: flow.id,
    updateLoginFlowBody: body,
    xSessionToken: sessionToken
    )

There are two required App Attest calls to create a key and use it to sign:

import CryptoKit
import DeviceCheck
import Foundation
import LocalAuthentication
import OSLog
import Security

public enum OryApiError: Error, LocalizedError {
case secureEnclaveError(String, OSStatus?)
case appAttestationNotSupported
case appAttestationError(String)
case biometricAuthenticationFailed(String?)
case biometricAuthenticationCancelled

public var errorDescription: String? {
switch self {
case .secureEnclaveError(let message, let status):
let statusString = status != nil ? " (Status: \(status!))" : ""
return "Secure Enclave Error: \(message)\(statusString)"
case .appAttestationNotSupported:
return "App Attestation is not supported on this device."
case .appAttestationError(let message):
return "App Attestation Error: \(message)"
case .biometricAuthenticationFailed(let message):
return
"Biometric authentication failed: \(message ?? "Unknown error")"
case .biometricAuthenticationCancelled:
return "Biometric authentication canceled by user."
}
}
}

public class OryApi {
public func createKey(challengeB64: String)
async throws -> (keyId: String, attestation: Data)
{
if #available(iOS 14.0, *) {
let service = DCAppAttestService.shared
guard service.isSupported else {
throw OryApiError.appAttestationNotSupported
}

let keyId: String
do {
keyId = try await service.generateKey()
} catch {
let errorMessage =
"Failed to generate key: \(error.localizedDescription)"
throw OryApiError.appAttestationError(errorMessage)
}

let challenge = Data(base64Encoded: challengeB64)!
let attestation = try await service.attestKey(
keyId,
clientDataHash: challenge
)

return (keyId, attestation)
} else {
// Fallback for older iOS versions
throw OryApiError.secureEnclaveError(
"iOS 14.0 or newer is required for App Attestation.",
nil
)
}
}

public func signWithKey(keyId: String, challengeB64: String)
async throws -> Data
{
if #available(iOS 14.0, watchOS 14.0, *) {
let context = LAContext()
let reason = "Authenticate to sign in"
do {
try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason
)
} catch let error as LAError {
switch error.code {
case .userCancel, .appCancel, .systemCancel, .userFallback:
throw OryApiError.biometricAuthenticationCancelled
default:
throw OryApiError.biometricAuthenticationFailed(
error.localizedDescription
)
}
} catch {
throw OryApiError.biometricAuthenticationFailed(
error.localizedDescription
)
}

let challenge = Data(base64Encoded: challengeB64)!
let assertion = try await DCAppAttestService.shared
.generateAssertion(keyId, clientDataHash: challenge)

return assertion
} else {
throw OryApiError.secureEnclaveError(
"iOS 14.0 or newer is required for App Attestation.",
nil
)
}
}
}

First factor with PIN

warning

The first-factor signing key is attested over SHA256(nonce ‖ transport_public_key), not the bare nonce used by the second-factor guide above. Using the bare nonce here fails with Unable to validate the key attestation: wrong challenge.

This section implements PIN enrollment, first-factor login, PIN change, and secret rotation on iOS. It builds on the App Attest signing key from the second-factor guide above and adds the transport, sealing, and PIN layers. Read it together with Client implementation requirements — the comments in the code below are normative and restate those rules inline.

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 to attestKey as clientDataHash.
  • Create the signing key with .biometryCurrentSet in its access control, and enroll it with "user_verification": "platform" and no pin_protected or transport_public_key.
  • At login, submit only client_key_id and signature (the assertion over the bare nonce) — omit pin_proof.

For the App Attest generateKey / attestKey / generateAssertion scaffolding, reuse the OryApi helper from the second-factor guide. 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:

  1. Create a fresh PinCeremony — a new ephemeral transport key.
  2. Call signRotationChallenge(nonce:appAttestKeyId:) with the flow nonce and the key's App Attest key id. It signs the raw nonce ‖ t_pub concatenation, unhashed.
  3. Submit the rotate_secret payload with client_key_id, the fresh transport_public_key, and that signature (see Rotating the PIN secret).
  4. Open the new secret from the response's continue_with with openSealedSecret, exactly as at enrollment.
  5. Capture the user's PIN — a new one if they forgot the old — and PinVault.seal the 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.