Home → Engineering
Don’t Say “I Have an SMS Code, I’m Safe”: Step-Up Authentication with Passkeys for Withdrawals
Saturday, 02:17. The markets closed last night, the dealing desk is at home, and back office
opens Monday at 09:00. From a customer’s web trader session, a new IBAN
is added. Four minutes later, from the same session, a $4,800 withdrawal
request arrives. The JWT is valid: the signature is correct, exp has not
passed, sub is the right customer. Free margin is sufficient, the amount is below
the auto-approval limit. The withdrawal is approved; the money reaches the new account within
seconds through instant transfer.
Monday, 09:04, the customer calls: “There is no money in my account.” The token was taken by a “PDF converter” browser extension they had installed a week earlier.
- A JWT verifies the bearer, not the identity. Whoever holds the token owns the request.
- Asking for the password again or sending an SMS code is not enough. Whoever stole the token usually has the password too; a one-time code is relayed in the same second by real-time phishing.
- A passkey is bound to the domain and the private key never goes to the server. That is why it is phishing-resistant.
- The challenge must be bound to the amount and the IBAN. This is exactly what PSD2 calls dynamic linking: if the amount or the payee changes, the approval is invalid.
- The threshold is not the only signal, and the signals are checked again at approval time. A new IBAN, an unknown device, a recently changed password or passkey — an account that was clean at request time may not be clean two minutes later.
- Step-up is only as strong as its weakest back door. Adding an IBAN, registering a passkey and creating an API key need the same protection.
What a JWT proves, and what it does not
On the platform, the customer logs in to the web trader or the mobile app and receives a JWT. Opening a chart, sending an order, watching open positions, downloading a statement — all with the same token. For most actions this is the right design.
When the backend validates a JWT, it actually knows three things: we issued the
token (signature), it has not expired (exp), and it names
this customer (sub). The one thing it does not know is the most important one.
// JWT filter succeeded. What we know:
// - We signed this token (signature)
// - It has not expired (exp)
// - It says "sub": "client_48213"
// WHAT WE DO NOT KNOW:
// - Is the sender of this request really client_48213?
Client client = verifyJwt(request.header("Authorization"));
A JWT is a bearer token. Bearer means “the one who carries it”: whoever presents the token is treated as the owner. Think of cash — a $100 bill that falls out of your pocket belongs to whoever picks it up. Your name is not printed on it.
On trading platforms, stealing a token is easier than people think:
-
Infostealers and malicious extensions. They spread by targeting finance users
and collect the browser’s cookie and token storage in bulk. Because the malware reads the
browser’s own storage, an
HttpOnlycookie does not save you here. -
XSS. A third-party chart or news widget embedded in the web trader reaches the
token in
localStoragewith a single line. -
Logs. An API gateway that writes the
Authorizationheader as it is, an error tracking tool, a log level that “we turned on to debug a bug in production”. - A session left open. The shared computer in the office, an internet café, an unlocked screen.
A request made with a stolen token cannot be told apart from the real customer’s request.
The signature is correct, exp is valid, the format is perfect. The system does not
raise an error; the log says “200 OK”. If it is the weekend, nobody even looks
— the incident explodes in customer support on Monday morning.
A short exp helps but does not solve it: a 15-minute access token is 10 minutes more
than you need to add an IBAN and withdraw $4,800. The refresh token is usually stolen from the
same storage at the same time. And revoking a stateless JWT before it expires means keeping a
denylist — in other words, giving up being stateless.
Fintech adds one more thing that makes it worse: a payout is irreversible. A card payment has a chargeback; a wrong order at least has an opposite position. For money that has already reached someone else’s account through an instant transfer, in practice there is neither.
Why asking for the password again or an SMS code is not enough
The first reflex is familiar: “Let’s ask for the password again on withdrawal.” Or the fintech classic: “Let’s send an SMS code.” Both gain you something — but what they gain depends on what the attacker has in their hands.
| Method | Token theft | Real-time phishing (code relay) | Infostealer | SIM swap |
|---|---|---|---|---|
| Password re-prompt | Stops it | Does not stop it | Usually does not stop it (the password is saved in the browser too) | Not affected |
| SMS OTP | Stops it | Does not stop it | Stops it | Does not stop it |
| TOTP (authenticator app) | Stops it | Does not stop it | Stops it | Not affected |
| Passkey (with user verification) | Stops it | Stops it | Stops it in most cases | Not affected |
The shared weak point of one-time codes is this: the code does not know where it is
typed. The customer enters the 6-digit code on a phishing login page at
sertacyildirim-invest.com that appeared in an ad; the attacker relays that code to
the real platform in the same second. The code is valid, because it really is valid.
Why a passkey is different
During registration the device generates a key pair. The private key
never goes to the server: it stays on the device (Secure Enclave, TPM) or in an
end-to-end encrypted password manager; only the public key goes to the server.
And this key is bound to a domain, the rpId: sertacyildirim.com.
If the customer lands on a phishing site, the browser never offers that passkey
— the domain does not match. There is no code to copy and relay. On top of that, the signed
data contains an origin field, and it is filled by the browser itself,
not by the page’s JavaScript.
The flow: from withdrawal request to instant transfer
The big picture first. This time it is the customer, withdrawing to their own account:
Username + password
|
Authentication -> JWT (amr: ["pwd"])
|
Chart, order, position, statement --------------> JWT is enough
|
POST /withdrawals (account: 2104551, $4,800, IBAN ...4417 / Ahmet Y.)
|
Free margin check (account locked)
|
Risk-based decision: threshold, new IBAN, new device/country, recent security change
|
Withdrawal record -> status: PENDING_STEP_UP
|
Server creates challenge -> SHA-256(nonce | amount+IBAN digest), TTL 2 min, single-use
|
Confirmation screen: "4,800.00 USD -> TR.. 4417 (Ahmet Y.)" -> customer taps the button
|
Face ID / fingerprint / PIN -> device signs with the private key
|
Backend verifies: origin (allowlist), rpIdHash, challenge, UV flag, signature
|
Risk signals AGAIN -> free margin AGAIN -> reserve funds -> APPROVED
|
Payout outbox (same transaction) -> bank integration -> instant transfer / SWIFT
1. Risk-based decision: JWT or step-up?
“$1,000 and above” is a good start. But if it is applied per transaction, the attacker’s answer is obvious: $999 × 5. And if a customer has more than one trading account, a per-account threshold has the same hole: $999 from account one, $999 from account two.
The threshold must be calculated at client level, across all accounts, in USD equivalent, over a sliding window. An internal transfer to another customer also counts; the money may not leave the platform, but it leaves the customer.
And the threshold is not the only signal. In the 02:17 incident there were two signals as important as the amount: the IBAN had been added four minutes earlier and its owner was not the customer.
| Signal | Why suspicious? | Action |
|---|---|---|
| Last 24 hours total ≥ $1,000 | Threshold | Step-up |
| Target IBAN added in the last 48 hours | In an account takeover, the attacker’s first job is to add a payee | Step-up; above the threshold, + 24-hour hold |
| IBAN owner does not match the customer’s name | Third-party withdrawal | Reject / manual review |
| Unknown device or country | The token is being used somewhere else | Step-up (even below the threshold) |
| Password, e-mail or passkey changed in the last 24 hours | This is exactly the order of an account takeover | Withdrawal lock |
static final BigDecimal THRESHOLD_USD = new BigDecimal("1000");
Action decide(Client c, WithdrawalRequest r) {
if (hasSecurityChangeWithin(c.id, Duration.ofHours(24))) return Action.WITHDRAWAL_LOCKED;
if (!ibanOwnerMatches(c, r.iban())) return Action.MANUAL_REVIEW;
// All trading accounts, USD equivalent, approved + completed + internal transfers
BigDecimal last24h = withdrawalRepo.sumUsdByClient(c.id, Duration.ofHours(24));
boolean overThreshold = last24h.add(r.amountUsd()).compareTo(THRESHOLD_USD) >= 0;
boolean newIban = ibanRepo.age(c.id, r.iban()).compareTo(Duration.ofHours(48)) < 0;
boolean newPlace = !deviceRepo.isKnown(c.id, r.deviceId()) // unknown device
|| !geoRepo.isKnownCountry(c.id, r.country()); // or country
if (overThreshold && newIban) return Action.STEP_UP_AND_HOLD;
if (overThreshold || newIban || newPlace) return Action.STEP_UP;
return Action.JWT_SUFFICIENT;
}
@Transactional
Response requestWithdrawal(Client c, WithdrawalRequest r) {
// Parallel requests from the same client must see each other:
// if two concurrent $600 requests both read "total 0", the threshold is skipped.
clientRepo.lock(c.id); // SELECT ... FOR UPDATE
// The account is read under a lock too: the client lock protects against other
// withdrawals, not against the trading engine opening a position at the same time.
TradingAccount acc = accountRepo.lockAndFind(r.accountId(), c.id); // FOR UPDATE
if (acc.freeMargin().compareTo(r.amount()) < 0) return error("INSUFFICIENT_FREE_MARGIN");
Action a = decide(c, r);
if (a == Action.WITHDRAWAL_LOCKED || a == Action.MANUAL_REVIEW) return hold(c, r, a);
if (a == Action.JWT_SUFFICIENT) {
// This is the path that runs most often: most withdrawals are below the threshold.
// Reserve and outbox are here too; exactly the same as on the step-up path.
Withdrawal w = withdrawalRepo.create(c.id, r, Status.APPROVED);
acc.reserve(r.amount());
payoutOutbox.add(w.id, w.releaseAt());
return ok(w);
}
Withdrawal w = withdrawalRepo.create(c.id, r, Status.PENDING_STEP_UP);
if (a == Action.STEP_UP_AND_HOLD) w.setReleaseAt(now().plusHours(24)); // even if approved, the payout waits
return stepUpRequired(w.id); // 403 { "status": "STEP_UP_REQUIRED", "withdrawalId": 7731 }
}
Three details:
-
Without a lock, the threshold is a race condition. A second request fits
between the check and the insert. If the check and the record live in the same database,
FOR UPDATEis enough; it does not matter how many instances of the service are running, they all wait for the same lock. You need a distributed lock only when the data is spread across more than one store — sharding, separate databases, or state kept in Redis. -
The below-threshold path also goes through reserve and outbox. This is the path
that runs most often; if you skip the reserve here, the same money goes to the bank and stays in
the account as margin at the same time, and if you skip the outbox, the withdrawal shows
APPROVEDbut the payout message is never written. We will come back to what both of those mean. -
Why not 401? When the web trader receives a 401 it says “session
expired” and sends the user to the login page; open charts, drawings and the order ticket
are lost. The client must be able to tell apart the case “the session is valid, but
this withdrawal needs stronger authentication”. If you want a standard way, RFC 9470
defines it as 401 plus
error="insufficient_user_authentication"in theWWW-Authenticateheader. Which status code you choose matters less than making sure the client does not confuse the two.
2. The challenge: created by the server, bound to amount and IBAN
StepUpChallenge createChallenge(Client c, long withdrawalId) {
Withdrawal w = withdrawalRepo.find(withdrawalId, c.id, Status.PENDING_STEP_UP);
byte[] nonce = new byte[32];
SECURE_RANDOM.nextBytes(nonce); // CSPRNG, unpredictable
// digest = SHA-256(accountId | amount | currency | iban)
// challenge = SHA-256(nonce | digest) -> the data the device signs now covers the transaction
byte[] challenge = sha256(concat(nonce, w.digestBytes()));
redis.set("stepup:" + w.id,
json(new PendingChallenge(c.id, w.id, base64url(nonce), w.digest())),
SetArgs.ex(120)); // TTL: 2 minutes
var options = new PublicKeyCredentialRequestOptions(
challenge,
RP_ID, // "sertacyildirim.com"
passkeyRepo.credentialIds(c.id), // allowCredentials: ONLY this client's keys
"required", // userVerification: biometrics or PIN
60_000 // timeout
);
// The confirmation screen shows the SERVER's values: amount, currency, masked IBAN, account holder
return new StepUpChallenge(options, w.summary());
}
Every line has a reason:
-
challenge = SHA-256(nonce | digest). If you generate a random challenge and only keep the digest in Redis, you still prevent the withdrawal from changing; but then the link depends on the server’s record that “this challenge belonged to that withdrawal”, and the data the device signs does not contain the amount. When the digest is inside the challenge, the signature covers the amount and the IBAN cryptographically: six months later, in an audit, you can show that “the customer signed exactly this amount”. The customer approves $1,200; $4,800 does not go, and nothing goes to a different IBAN. -
allowCredentialscontains only the credentials of the customer in the JWT. An assertion signed with another customer’s passkey must not be accepted — we will check this again on the backend. - The TTL is short. The challenge’s job is freshness: “This signature was produced now, for this withdrawal.”
- The response has two parts: the WebAuthn options and the withdrawal summary. The confirmation screen draws the amount from what the server returned, not from the client’s memory.
In Europe, PSD2’s Strong Customer Authentication (SCA) rules require dynamic linking for payment transactions: the authentication code must be specific to the amount and the payee, and it becomes invalid if either changes. Putting the digest into the challenge is the implementation of that; the signature itself is bound to the transaction. The amount not being shown in the authenticator dialog is a separate problem, and we will get to it below with SPC. Which regulation your company falls under is a question for the compliance team; but whichever country you are in, this is the right design.
Creating the challenge on the client side, or using a predictable value such as
Date.now(). If the server does not create and store the challenge, a signature
captured yesterday can be used again today (replay). The challenge is a nonce; the server must
be the one that issues it and the one that verifies it.
3. The confirmation screen: the customer must see what they approve
// The server response has two parts: WebAuthn options + withdrawal summary
const { publicKey, withdrawal } = await api.post(`/withdrawals/${withdrawalId}/challenge`);
// The passkey dialog does NOT show the amount. Amount and IBAN must be rendered on
// our confirmation screen, right above the button, from values sent by the server.
renderConfirmation(`${withdrawal.amount} ${withdrawal.currency} -> ${withdrawal.maskedIban} (${withdrawal.holderName})`);
// The passkey dialog must not open before the customer has read the amount and tapped the button.
// (Browsers expect a user gesture for the WebAuthn call anyway.)
await confirmButtonClicked();
const assertion = await navigator.credentials.get({
publicKey: {
challenge: base64urlToBuffer(publicKey.challenge),
rpId: publicKey.rpId,
allowCredentials: publicKey.allowCredentials.map((c) => ({
type: "public-key",
id: base64urlToBuffer(c.id),
})),
userVerification: "required",
timeout: 60000,
},
});
await api.post(`/withdrawals/${withdrawalId}/confirm`, {
credentialId: assertion.id,
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
signature: bufferToBase64url(assertion.response.signature),
});
In modern browsers, PublicKeyCredential.parseRequestOptionsFromJSON() and
assertion.toJSON() do these base64url conversions for you; keep the helper functions
if you still support older browsers.
The native dialog the customer sees is short: “Use your passkey for sertacyildirim.com?” and Face ID. The amount is not there. That is why the confirmation screen right above it must show “4,800.00 USD → TR.. 4417 (Ahmet Y.)” in large, clear text, and the Face ID dialog must not open before the customer taps the button. The last four digits of the IBAN and the account holder’s name are the only moment when the customer can say “this is not my account”.
4. Backend: verify the signature, re-check the risk and the free margin
static final Set<String> ALLOWED_ORIGINS = Set.of(
"https://sertacyildirim.com",
"https://trader.sertacyildirim.com", // web trader on a subdomain; same rpId
"android:apk-key-hash:<app-signing-hash>"); // Android native app: the origin is not https
Response confirm(Client c, long withdrawalId, AssertionDto a) {
// (the client gets the same error for all of them: VERIFICATION_FAILED. Details only in the log.)
// 1. Read the challenge and delete it AT THE SAME TIME (GETDEL). If GET and DEL are separate,
// two parallel requests can use the same challenge.
PendingChallenge pc = redis.getdel("stepup:" + withdrawalId);
if (pc == null || !pc.clientId.equals(c.id)) return reject("challenge");
// 2. Does this credential belong to this client?
Passkey p = passkeyRepo.find(a.credentialId);
if (p == null || !p.clientId.equals(c.id)) return reject("credential");
// 3. clientDataJSON: what the browser says it had signed
ClientData cd = ClientData.parse(a.clientDataJSON);
if (!cd.type.equals("webauthn.get")) return reject("type");
byte[] expected = sha256(concat(base64urlDecode(pc.nonce), pc.digestBytes())); // nonce + transaction digest
if (!constantTimeEquals(cd.challengeBytes, expected)) return reject("challenge");
if (!ALLOWED_ORIGINS.contains(cd.origin)) return reject("origin"); // <- phishing is caught here
if (Boolean.TRUE.equals(cd.crossOrigin)) return reject("crossOrigin"); // call from inside an iframe
// 4. authenticatorData: what the device says
AuthenticatorData ad = AuthenticatorData.parse(a.authenticatorData);
if (!Arrays.equals(ad.rpIdHash, sha256(RP_ID))) return reject("rpIdHash");
if (!ad.userPresent()) return reject("UP");
if (!ad.userVerified()) return reject("UV"); // <- not "the device is there" but "the owner is there"
// 5. Signature: authenticatorData || SHA-256(clientDataJSON), with the stored public key
byte[] signedData = concat(a.authenticatorDataRaw, sha256(a.clientDataJSONRaw));
if (!verifyCoseSignature(p.publicKey, signedData, a.signature)) return reject("signature");
// 6. signCount: a cloned-authenticator signal. Synced passkeys usually always send 0.
if ((ad.signCount != 0 || p.signCount != 0) && ad.signCount <= p.signCount) {
alert("possible cloned authenticator", p.id);
return reject("signCount");
}
passkeyRepo.updateSignCount(p.id, ad.signCount);
// 7. Authentication is done. Now the risk and the money side (in a separate bean, see below).
return approvalService.approveAndReserve(withdrawalId, c.id, pc.digest, p) ? ok() : reject("status");
}
If the origin line were an exact comparison against a single value, it would break two things: if
the web trader lives on a subdomain such as trader.sertacyildirim.com, the passkey
works (same rpId) but the check rejects it; and the Android app sends its origin not as
https:// but in the form android:apk-key-hash:. The right tool is an
allowlist. The crossOrigin field is a free signal too: if it is true,
the call came from inside an iframe, and our page never does that.
Authentication is done, but the job is not. Two minutes passed between the challenge and the confirmation. In those two minutes two things may have changed: if the market is open, open positions moved and the free margin that was sufficient at request time may not be sufficient now; and the account itself may have changed — in a moment, why.
@Service // a SEPARATE bean: in the same class as confirm(), @Transactional would not apply (see below)
class ApprovalService {
@Transactional
boolean approveAndReserve(long withdrawalId, long clientId, String digest, Passkey p) {
Withdrawal w = withdrawalRepo.lockAndFind(withdrawalId, clientId); // FOR UPDATE
if (w.status != Status.PENDING_STEP_UP || !w.digest.equals(digest)) return false;
// Risk signals AGAIN at approval time: an account that was clean at request time may have changed
if (hasSecurityChangeWithin(clientId, Duration.ofHours(24))) { w.hold("SECURITY_CHANGE"); return false; }
if (p.createdAt().isAfter(now().minusHours(48))) { w.hold("NEW_CREDENTIAL"); return false; } // new passkey: cooling-off
TradingAccount acc = accountRepo.lockAndFind(w.accountId); // FOR UPDATE
// Is the free margin sufficient NOW? (real-time equity - used margin)
if (acc.freeMargin().compareTo(w.amount) < 0) {
w.reject("INSUFFICIENT_FREE_MARGIN"); // the client gets a notification
return false;
}
acc.reserve(w.amount); // this amount can no longer be used as margin for a new position
w.approve();
payoutOutbox.add(w.id, w.releaseAt()); // if the approval commits, so does the payout message; both or neither
return true;
}
}
The first version did not repeat the risk signals at approval time, and that left a sequencing
hole: the attacker first creates the withdrawal request — at that moment there is no
security change in the last 24 hours, so the request becomes PENDING_STEP_UP. Then
they register their own passkey (if the customer has no passkey at all, through e-mail
verification; the infostealer may have taken the e-mail session too) and approve the request with
this new passkey. The rule “passkey changed in the last 24 hours → withdrawal lock”
never fires, because it was only checked at request time. Running the rule at approval time as
well, and holding the request when the passkey is younger than 48 hours, closes this path.
If you skip the reserve, this happens: the withdrawal is approved, the payout enters the queue, and
the customer opens a new position with the same money. While the money is on its way to the bank,
it is still sitting in the account as margin. If you skip the outbox, the opposite happens: the
withdrawal shows APPROVED, but the payout message was never written.
The code looks like Spring, and in Spring’s default proxy-based setup
@Transactional does not apply to a call made from inside the same
class. If confirm() and approveAndReserve() were in the same
class, the FOR UPDATE locks would be released immediately by autocommit, the
approval and the outbox would not be atomic, and the “both or neither” guarantee
would disappear silently — no error, no log, and it works in the test
environment. Use a separate bean or a TransactionTemplate. This is the kind of
thing I mean by “see what is behind it”.
Do not write this by hand in production. A mature library such as
webauthn4j or Yubico’s
java-webauthn-server does the CBOR/COSE parsing and most of the signature checks. The
code above is there so you can see what is behind the library — because when
you misconfigure it, you need to know which check has silently been turned off.
Two checks are skipped especially often:
-
The UV flag must be checked on the backend. Sending
userVerification: "required"in the options is a request, not a guarantee. If there is an XSS through a widget in the web trader, the attacker’s script can make the same call with"discouraged"; some security keys then sign with a single touch, without a PIN. The decision belongs to the flag inside the signedauthenticatorData, not to the client. - Credential ownership must be checked. The signature can be valid — but with another customer’s passkey. “The signature is valid” and “it is this customer’s signature” are not the same thing; it is the same lesson we learned with the JWT.
The payout consumer must also be idempotent: processing the same outbox message twice means sending the same transfer twice. That side of the topic is in How Many Times Does a Message Arrive?.
Back doors: a steel lock on the front door, the window open
The attacker has the JWT, not the passkey. They cannot approve the withdrawal. Good.
But what if they go to Settings → Security → Add passkey and register their own passkey? If that endpoint only asks for a JWT, 30 seconds later they approve the $4,800 withdrawal with their own phone and a perfect signature. All of the backend’s checks pass — because it really is a registered credential now. The 48-hour cooling-off above slows them down; it does not stop them. What stops them is that endpoint asking for step-up too.
On a trading platform, a few fintech-specific doors join this list:
| Action | Without step-up, what does the attacker do? |
|---|---|
| New IBAN / crypto wallet address | They decide where the money goes; every later withdrawal goes there |
| Passkey registration | They add their own credential, then approve everything in a “legitimate” way |
| Passkey removal | They delete the customer’s credential and force the system onto the fallback method |
| Internal transfer to another customer | They move the money to their own account without ever sending it to a bank, then withdraw from there |
| API key creation | With a key that has withdrawal or transfer permission, they bypass every UI check |
| E-mail / phone change | They take over withdrawal notifications and the recovery channel |
| Password change | They lock the real customer out of the account |
| Account recovery | “I lost my phone” bypasses the whole chain |
The API key row is especially sneaky. This door, opened for customers who do algorithmic trading, often lives in a separate team as a “developer feature” and never goes through a security review. API keys must have no withdrawal permission by default, must be IP-allowlisted, and withdrawal permission must be enabled through a separate step-up.
Two hard cases remain:
-
What if the customer has no passkey at all? You cannot approve the first passkey
with a passkey. The common approach here is e-mail verification + notification + a
cooling-off period: no large withdrawal is possible in the first 24–48
hours with a newly registered passkey or IBAN. The
NEW_CREDENTIALline above is exactly that. Many exchanges and trading platforms lock withdrawals for a while after a security setting changes for exactly this reason. - The fallback. If there is an option “I don’t have my passkey with me, let me approve with SMS”, the attacker never bothers with the passkey. From that moment on, the strength of your step-up is the strength of SMS.
The elevated session trap
The easy way is this: after the passkey is verified, issue a new JWT carrying
amr: ["pwd", "hwk"] and allow all sensitive actions for 10 minutes. If the customer
adds an IBAN and withdraws right away, they should not have to look at Face ID twice.
It sounds reasonable. But the two approaches approve different things:
| Time window (10-minute elevated token) | Transaction-bound approval | |
|---|---|---|
| What is approved | “This person was here 10 minutes ago” | “This person approved this amount to this IBAN” |
| With an XSS in the web trader | The attacker’s script does anything it wants for the whole window, invisible to the customer | The attacker has to trick the customer into a separate Face ID for every withdrawal (man-in-the-browser, below) |
| UX | One approval for a series of actions | One approval per sensitive action |
| Right place | Statement download, tax documents, viewing settings | Withdrawal, new IBAN, passkey registration, API key |
The XSS row matters, and it should be read honestly for both sides. The elevated token goes to the legitimate customer’s browser; if there is a malicious script on the page, for 10 minutes it does anything it wants with that token and the customer sees nothing. Transaction-bound approval does not remove this — a script on the same origin can show the customer’s withdrawal on the screen while opening a request to its own IBAN in the background, and the origin check passes because the origin really is correct. The difference is this: the attacker now has to trick the customer into a separate approval for every withdrawal; there is no generic permission to steal, only a single-use signature bound to one withdrawal.
When money is involved, the second one. A few seconds of Face ID are cheaper than an attack window.
What passkeys do not solve
A passkey is a strong tool, but not a silver bullet. Knowing its three limits tells you what you need to put on top of it.
Man-in-the-browser. A malicious extension in the customer’s browser can show “TR.. 4417 (my own account)” on the screen while building the request with the attacker’s IBAN in the background. This is exactly what banking trojans have been doing for years. Because the passkey dialog does not show the amount, the customer looks at Face ID and approves — the challenge is bound to the attacker’s withdrawal and the signature is perfect. Mitigations:
- Out-of-band notification. A push to the mobile app: “$4,800 to TR.. 9021 (Mehmet K.). Tap if this is not you.” Whoever compromised the browser cannot change the notification on the phone.
- New payee hold. A large withdrawal to an IBAN that has never received money before waits a few hours and can be cancelled by the customer. The speed of instant transfers is not a feature here; it is a risk.
-
Account holder name check. Withdrawals only to an account in the customer’s
own name. When the customer is Ahmet Y. and the notification above says “Mehmet K.”,
that alone makes many attacks visible — and the
MANUAL_REVIEWrule does not even leave it to the notification; the request stops at the first step. - Secure Payment Confirmation (SPC). A WebAuthn extension that shows the amount in the browser’s own native dialog. It was designed for exactly this problem, but browser support is still limited.
Synced passkey = cloud account. If the passkey is synced through iCloud Keychain
or Google Password Manager, the private key does leave the device (encrypted), and whoever takes
over that cloud account can reach the passkey on a new device. The risk does not disappear; it
moves from the phone to the cloud account. The BE (backup eligible) and
BS (backup state) flags in authenticatorData tell you whether the
credential is synced; for corporate accounts or very large withdrawals you can require a
device-bound credential.
Social engineering. “Hello, I’m calling from the platform’s security team, there is a suspicious login on your account, please accept the approval request to protect it.” The customer approves with their own hand, their own Face ID. There is no technical fix; but the line on the confirmation screen — “If someone called you and asked you to approve this, it is fraud” — the account holder name and the cooling-off period help here too.
What to monitor
- Step-up completion rate — a sudden drop is either a UX problem or people who hold a token but not a passkey.
- Failed verifications per client — a few are normal, dozens are not.
- Withdrawals just below the threshold — a pile-up in the $950–999 range is a structuring attempt; look at client level and across accounts.
- Time from new IBAN to withdrawal — a withdrawal within an hour of adding an IBAN deserves its own alert.
- Requests put on hold at approval time (
SECURITY_CHANGE,NEW_CREDENTIAL) — each one is either an attack attempt or an impatient customer; both should be seen. - Off-hours and weekend withdrawals — the hours nobody watches are the hours the attacker knows about.
- Approvals rejected for insufficient free margin — shows the latency between step-up and confirmation has grown too long.
- signCount regression — a non-zero counter going backwards is a direct alert.
- Creation of API keys with withdrawal permission — should be a rare event; every one should be visible.
Checklist
- Is the threshold at client level, across all accounts, in USD equivalent, over a sliding window?
- Are internal transfers included in the threshold total?
- Does the decision look only at the amount, or are new IBAN, new device/country and recent security change signals too?
- Are the risk signals evaluated again at approval time? Is the cooling-off for a new passkey in the code?
- Is the threshold check locked against concurrent requests? Is the account read under a lock too?
- Does the below-threshold path also go through reserve + outbox?
- Is the challenge created server-side with a CSPRNG, and is it single-use (atomic GETDEL)?
- Does the challenge include the amount + IBAN digest, so the signature is cryptographically bound to the transaction (dynamic linking)?
- Does the confirmation screen show the amount, the last digits of the IBAN and the account holder name from server values, and does the passkey dialog open only after the button?
- Does the backend check
type,rpIdHashand the UV flag; does the origin allowlist cover the web trader subdomain and the mobile app; iscrossOriginrejected? - Is it verified that the credential belongs to the client in the JWT?
- At approval time, is the free margin re-checked and the amount reserved?
- Are the approval and the payout message in the same transaction (outbox), and does
@Transactionalreally apply (not a same-class call)? - Are new IBAN, passkey registration/removal, API keys, e-mail/phone change and account recovery under the same protection?
- Is there a fallback that bypasses the passkey? If so, its strength is the strength of your step-up.
- Does an out-of-band notification go out for large withdrawals?
Conclusion
That Saturday at 02:17, the JWT broke no rule. It was valid, its signature was correct, it had not expired. The free margin was sufficient, the amount was below the limit. The system did every check correctly — the problem was treating “valid token” and “real customer” as the same thing.
If these things had been in place that night: because the IBAN belonged to Mehmet K. and the customer was Ahmet Y., the request would have gone to manual review at the very first step. Even without that rule, had the new-IBAN endpoint asked for a passkey, the attacker would have stopped there. Even if not, the withdrawal would have fallen into step-up and a 24-hour hold on the “new IBAN + threshold” signal. Even past that, the customer’s phone would have received the notification “$4,800 to Mehmet K.’s account”. Not one control; layers, each catching what the previous one missed — defense in depth.
The code side is a few hundred lines. The real work is closing the doors around those lines — the new-IBAN endpoint, the API keys, the fallback, the $999 withdrawals — with the same seriousness.
Because a token is a string; it gets copied, logged, stolen. A passkey produces a signature but never sends the private key. The question to ask at payout is exactly this: not “Do you have the token?” but “Do you have the key?”