Technology Apr 17, 2026 · 10 min read

Strong Customer Authentication (SCA) in Swift

Strong Customer Authentication, or SCA, is a multi-factor authentication method that protects sensitive actions inside apps such as — logging in, viewing sensitive data, changing settings, transferring money, anything worth guarding really. It came out of the EU, but it's spread far enough that you'...

DE
DEV Community
by Luka Gujejiani
Strong Customer Authentication (SCA) in Swift

Strong Customer Authentication, or SCA, is a multi-factor authentication method that protects sensitive actions inside apps such as — logging in, viewing sensitive data, changing settings, transferring money, anything worth guarding really. It came out of the EU, but it's spread far enough that you've almost certainly used it without knowing the name.

A quick backstory: SCA comes from a law called PSD2 (Payment Services Directive 2). Think of PSD2 as the rulebook for payment services in the EU — and SCA is the chapter that says "one password isn't enough anymore."

Overview 📖

Logging into most apps is simple — email and password, done. For apps that don't hold anything sensitive, that's enough and encouraged. But once your app starts managing money, medical records, or anything a fraudster would want their hands on, password-only login stops being good enough. SCA is what you add on top.

When the server needs to verify that you are who you claim to be, it can ask for proof. That proof falls into three categories:

  1. Knowledge 🧠 — something you know. A password, a PIN, a security question. If someone stole your phone, they still wouldn't have this, but you can guess that it's the easiest information to get hold of out of these three.
  2. Possession 📱 — something you have. A trusted device, an SMS code sent to your phone number, or a code from an authenticator app. This proves you have physical access to something that both you and the server agreed to trust.
  3. Inherence 👤 — something you are. Face ID, a fingerprint, or other biometric verification. This one can't be forgotten, lost, or shared — it's tied to your body.

Knowledge, Possession and Inherence are general security concepts that existed long before PSD2, but for the sake of simplicity I'm going to call them PSD2 Categories in this blog and my code as well.

Now that we know the three categories, here's what SCA actually
enforces: no single category is enough.

If someone steals your password, they still can't get in — the server will demand proof from a second category, like an SMS code or Face ID. That's the core idea - now managing the behavior is our job.

The minimum is two distinct categories, but the server can raise the bar on the fly. A routine login might ask for a password and an SMS code. A large transfer might demand all three — something you know, something you have, and something you are. The server decides based on risk.

That's the concept. Now let's get into how I built it.

The Project 🛠️

When I first looked into SCA I tried to find Swift implementations to learn from and came up mostly empty. Most of what exists is buried inside private codebases at banks, payment processors, and fintechs. I wanted a clean, self-contained reference that anyone could clone, run, and poke at to understand how SCA actually works end-to-end. That's what this project is trying to be.

I built SCACore — a Swift package that implements the full SCA flow, plus a demo app that runs against a local mock server. No real backend, no network, no API keys, I tried to make the project as free of outside dependencies as possible. Everything runs in-process so the project stays runnable indefinitely.

The rest of this post walks through the architecture at a medium depth. The code itself is heavily commented — if you want the full picture, open the repo/clone the demo alongside this post.

The Three Phases 🔄

As frontend developers, we're never the source of truth — anything that can be verified gets checked with the backend. SCA is no exception. Every flow boils down to the same three-phase conversation between client and server.

Challenge — the client asks the server "I want to do X (login, read sensitive info, change settings), what do I
need to prove?" The server responds with a list of available
authentication methods, how many distinct categories are needed, and a challenge ID that ties everything together. The client's job is to satisfy the challenge; the server's job is to decide what satisfies it.

Proof — for each factor the user provides, the client packages it into an AuthenticationProof and submits it to the server. The server responds with either .partial(satisfiedCategories:) — meaning "good, but I still need more" — or .complete(AuthenticationResult) — meaning "you're done, here's your token." The client loops until it gets .complete.

Result — the final token is scoped to a specific operation
(.login, .payment, .settingsChange). A token issued for
.payment can't be reused for .settingsChange. The server enforces this on every request.

All three phases happen inside one function:
DefaultSCAService.authenticate(challenge:). It's the heart of the package and it's worth reading end-to-end if you want to see how the phases fit together.

Putting SCA in the Demo 🎬

Everything we've walked through so far comes together in the demo app as two login paths. One is the baseline. The other is an optimization on top of it. Both go through the same authenticate(challenge:) function, and the difference between them is entirely about how much the app has already prepared by the time SCA starts.

Manual login 🔨

The default path. The user types their email to identify themselves and their password as the knowledge factor, then provides one more factor from whatever the server offers for that user — an SMS or email OTP, an authenticator app code, or biometrics. More typing, solid security.
The email just tells the server which user to build a challenge for — the actual factors are what satisfy the challenge. The server returns a list of available methods, the client works through them one at a time, and once two distinct categories are covered the server issues a token.
This is the path most apps start with. It's reliable, it works everywhere, and it doesn't assume anything about the device or the user's prior sessions.

Cold-start 🧊

This is my favorite part of the project — the closest it gets to how real banking apps feel in daily use. When you open your banking app and scan your face, it feels like a single fast step. But the app is most likely checking two factors behind the scenes: your device ID is verified against the server's trusted device list (possession), and Face ID confirms it's really you (inherence). Two categories, zero typing, and a login experience that's fast, invisible, and genuinely secure. Contrast that with the old approach — password plus SMS code — and you'll see why this pattern took over.
I call it cold-start because the user doesn't warm up to it with any typing or tapping. They just launch the app and authenticate.
Here's how cold-start runs in the demo:

  1. The app launches with a saved user email and a trusted device flag already set (seeded from the InfoView in the demo app).
  2. The client verifies preconditions — biometrics enrolled, device trust active.
  3. It calls startChallenge(for: .login, context: .init(email: savedEmail)).
  4. The server returns a normal challenge with all available methods, including the password.
  5. The client enters the authentication loop. Device trust covers possession implicitly — the client doesn't need to provide an OTP.
  6. One category left to satisfy. The resolver prefers Face ID because no knowledge factor is collected.
  7. User scans their face. Server verifies. Token issued. Done.

Cold-start clears the challenge with device trust plus biometrics; manual login clears it with a typed password plus whichever second factor the user has enrolled. Same function, same flow, different starting state.
These two paths are what fit this app's UX. Nothing about the SCA service itself demands either of them. You could just as easily build an app that prompts for a PIN in a popup, offers a passcode-first flow, or lets the user pick between several entry points at launch. The service doesn't care how credentials arrive — only that they do.

Try It 🚀

The demo project repo is at https://github.com/GujMeister/Swift-SCA-Demo. If you want to check the package as a standalone repo try this link https://github.com/GujMeister/SCACore. Clone it, open in Xcode, run. The demo app has an Info screen with test users, a Debug panel for toggling server config and device trust, and the SCA flow wired up end to end.

If you want to understand the architecture beyond what's in this
post, start with DefaultSCAService.authenticate(challenge:) and
follow the numbered step comments — they map directly to the three
phases we walked through above.

Extras 🎁

A few design notes for readers who want to go a bit deeper.

Credential handoff

When the user taps Sign In, they've already typed their password into the login screen — there's no reason for the SCA flow to ask for it a second time. The SCAFlowCoordinator keeps a small cache of credentials the UI has already collected, keyed by PSD2Category:

swiftcoordinator.prefillCredential(state.password, for: .knowledge)
try await scaService.authenticate(challenge: challenge)

When the SCA service asks for a knowledge factor, the coordinator checks the cache first and returns instantly if there's a match — no screen, no prompt. If the cache is empty (like in cold-start), the flow prompts normally. Nothing forces the caller to prefill. You could kick off SCA with nothing cached and let the service collect everything through its normal loop, prompting for each factor in turn. This app chose the two paths that fit its UX best.

The server is the single source of truth

After every proof, the server tells the client which categories are satisfied. The client doesn't pre-plan the flow — it reacts one step at a time. Challenges aren't static. If biometrics fail halfway through, the client skips inherence and tries another category — no server round-trip to rebuild the plan. If the server decides mid-flow that device trust is no longer sufficient (a risk signal fired, a suspicious location, an unusual amount), it just returns .partial instead of .complete and the loop continues. The client never has to know why. That opacity is the point — fraud detection logic lives entirely on the server, where it can change every day without shipping a single app update.

Device trust is a hint, not a guarantee

When the client claims "this device is trusted," the server verifies independently against its own registry. Both sides must agree before possession is granted implicitly. Modeled as:

struct DeviceContext {
    let deviceId: String
    let claimsTrustedStatus: Bool
}

The name tells you it's an assertion, not a fact. The mock server stores trusted devices as a Set<String> and only grants implicit possession when the client's claim and the server's registry agree.
In the real world this is built on cryptographic key binding — where the private key physically cannot leave the hardware. I haven't implemented that here, but it's a nice rabbit hole and definitely something to keep in mind for the future.

For iOS developers

A note on architecture. The demo app isn't trying to show off a structure — it's built to teach SCA, not to impress with layering. Dependency injection is a lightweight service locator, views talk to observable ViewModels, and there's a basic separation between the package (SCACore), the mock provider layer (ProviderLayer), and the feature layer (FeatureLayer). That's it. The interesting logic lives in SCACore, and the demo is just a runnable showcase around it.
Read the code with that lens. The package is more production-minded with comments; the app is a vehicle to show it working.

DE
Source

This article was originally published by DEV Community and written by Luka Gujejiani.

Read original article on DEV Community
Back to Discover

Reading List