Writing

Designing an authentication SDK for five banks

An app has users. An SDK has consumers. When the SDK verifies identity and authenticates users for banks, those consumers are not really the engineers who call your API. They are the compliance departments, security auditors, and release managers standing behind those engineers. Designing for that audience changed almost everything I believed about API design. This is what survived.

Your API surface is a liability, so make it small

Every public symbol is a promise you will keep for years. Our SDK does a lot: authentication, OTP verification, liveness detection, document capture, deep linking, and a dozen more flows, each shipping and versioning on its own. None of that complexity is the partner’s problem. All sixteen-plus flows sit behind one facade:

public final class BankID {

    public static func configure(_ configuration: Configuration) throws -> BankID

    public func startAuthentication(presenting: UIViewController) async throws -> AuthenticationResult
    public func startOTPVerification(presenting: UIViewController) async throws -> OTPResult
    public func startLivenessCheck(presenting: UIViewController) async throws -> LivenessResult
    public func startDocumentCapture(presenting: UIViewController) async throws -> DocumentResult
    public func handle(_ url: URL) -> Bool

    public var session: Session? { get }
    public func signOut() async
}

Everything else is internal: the logic inside each flow, the video-encoding pipeline behind liveness capture, the transport layer talking to our backend. Not because it’s shameful, but because anything public gets depended on, and anything depended on stops being yours. The rule we enforced: a symbol becomes public when two banks ask for it, never when one might.

Versioning without trust

Semantic versioning is usually a courtesy. For us it was a contract with penalties: a breaking change means five banks re-run security review, and a bank that can’t upgrade is a bank stuck running flows that miss the latest fraud and liveness fixes. So we made compatibility mechanical rather than aspirational:

  • CI diffs the public interface of every pull request against the released one. An unapproved change to any public declaration fails the build, and the reviewer cannot miss it.
  • Deprecations ship with a working replacement and a two-version window, documented with dates, not vibes.
  • Partner theming and RTL localization live entirely in configuration, so a new brand or language never has to touch the API surface at all.

So far, no partner integration has received a breaking change. That sentence did more for adoption than any feature we shipped.

Design your errors like they’re API

The lazy version of an identity SDK throws invalidResponse and lets five bank teams file five identical tickets. We treated the error taxonomy as carefully as the happy path. Every error answers three questions: whose fault is it, is it retryable, and what should the user see?

public enum BankIDError: Error, Sendable {
    /// Recoverable by the user, message included and localized.
    case userRecoverable(UserRecoverableError)
    /// The integration is wrong. Fails loudly in debug, includes a fix hint.
    case integration(IntegrationError)
    /// The platform said no (camera permission denied, liveness unsupported on this device).
    case capability(CapabilityError)
    /// Transient. Safe to retry with the included policy.
    case transient(retryAfter: Duration)
    /// The session is no longer valid. Only exit: re-authenticate.
    case sessionInvalidated(reason: InvalidationReason)
}

The one that pays rent is integration: misuse fails immediately and explains itself (“configure was called twice”, “startAuthentication called before configure”). SDK support load is mostly other people debugging your silence. We chose to be loud.

The integration is the product

The measure of an SDK is the afternoon a stranger spends integrating it. We optimized that afternoon deliberately: a reference app that exercises every flow, a sandbox backend with deterministic test identities (including one built to fail liveness on purpose, for rehearsing the unhappy paths), and documentation whose every code block is compiled in CI. If a snippet in the docs stops building, the build breaks before the trust does.

An SDK ships its assumptions. Every implicit expectation about threading, state, or timing becomes an integration bug in someone else’s codebase.

What five banks taught me

One consumer teaches you nothing about your API. You’ll bend the SDK around their quirks and call it design. Fifty consumers and you can’t talk to any of them. Five was the forge: enough perspectives to burn away everything idiosyncratic, few enough that every integrating engineer had my phone number. The API that survived five compliance departments, five brands, and full parity with a separate Android team is the simplest one I’ve ever shipped. That is not a coincidence.