Is SMS OTP enough for EU and US compliance? NIST, PSD2, GDPR
Where SMS one-time codes satisfy the rulebooks and where they don't, the SIM-swap and phishing-proxy failure modes, and the audit trail to keep on Infrai.
Short answer: an SMS code is a legitimate possession factor for ordinary logins in both the US and the EU, it is not sufficient on its own for a PSD2 payment, and NIST has treated the public phone network as a restricted channel since the 2017 revision of SP 800-63B. Nobody is going to fine you for shipping it. They may well fine you for shipping it as the only control on a money movement. Infrai’s SMS routes are what this piece calls against, because the compliance work lands in which route you pick and what you keep afterwards.
The regulatory question and the security question have different answers, and conflating them is how teams end up with a control that passes an audit and loses a customer’s balance.
What each rulebook actually asks for
| Regime | What it demands | Does an SMS code satisfy it? |
|---|---|---|
| NIST SP 800-63B, AAL2 | Two distinct factors; out-of-band authenticators over PSTN are restricted, with a risk assessment and a migration plan | Yes, conditionally — you must document why, and warn users |
| PSD2 strong customer authentication | Two independent categories, plus dynamic linking of amount and payee for remote payments | No, not by itself — the code must carry the transaction details |
| GDPR Art. 5 and 32 | Minimisation, storage limitation, appropriate security for the phone number as personal data | Neutral — it’s your retention and processor terms that decide |
| Generic US sectoral guidance | ”Multi-factor authentication” without naming a technology | Yes |
Read the second row twice. Under the EU rules, a code that just says “your code is 573104” is not dynamically linked to anything, so it can be relayed by an attacker into a different payment for a different amount — which is exactly the attack dynamic linking exists to stop.
The two attacks that decide your risk register
SIM swap is the one that gets press. An attacker convinces a carrier to port the number, and every possession-based control tied to that number moves with it. Carriers in the US and much of the EU have tightened their process, but “tightened” isn’t “solved”, and you have no API to check it.
The quieter one is a real-time phishing proxy. The victim types the code into a page the attacker controls and the attacker replays it within its TTL, which defeats SMS codes, emailed codes and TOTP apps equally — every shared-secret scheme is relaying a string a human just read.
Only a bound credential survives that, which in practice means WebAuthn or a passkey.
So the honest hierarchy is: SMS OTP beats a password alone by a wide margin, TOTP beats SMS on possession risk, and a passkey beats both on phishing. Pick according to what the account can lose.
Dynamic linking is where the managed loop stops
The managed OTP route is the right default for login. The gateway generates the code, stores it, expires it and counts attempts, and the request body carries only the destination and a template name:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/sms/otp" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"to": "+447700900204", "template": "login_code_en"}'
{
"ok": true,
"data": {
"request_id": "otpreq_9d3c17ba54",
"sent": true
}
}
That body is the limitation, and it’s worth stating plainly: with only to and template, you can’t inject this payment’s amount and payee into the message, so the managed loop can’t produce a PSD2-compliant dynamically linked code. For that flow you drop to the templated send and own code generation, TTL and attempt counting yourself:
curl -sS -X POST "https://api.infrai.cc/v1/sms/send" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"to": "+447700900204",
"template_id": "smstpl_pay_confirm_en",
"template_vars": {"amount": "EUR 240.00", "payee": "NORTHWIND LTD", "code": "573104"},
"from": "AcmeBank"
}'
Generating that code with a CSPRNG, storing only its hash, expiring it inside five minutes and capping attempts at five is now your job. That’s a real trade-off against the managed route, and if your product never moves money, don’t take it on.
Keep the evidence, then stop keeping it
Two obligations pull in opposite directions here. An auditor wants to see that a specific code was sent to a specific number at a specific time; GDPR’s storage limitation says you don’t get to keep that forever.
The event timeline is the audit side:
curl -sS "https://api.infrai.cc/v1/sms/events/sms_4Hd8nRbW1kYt" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{ "type": "queued", "occurred_at": "2026-07-26T10:02:04Z", "detail": null },
{ "type": "sent", "occurred_at": "2026-07-26T10:02:05Z", "detail": { "vendor": "tencent_sms" } },
{ "type": "delivered", "occurred_at": "2026-07-26T10:02:11Z", "detail": null }
],
"next_cursor": null
}
}
Retention is a tier-bounded setting rather than an infinite archive — a retention.days value outside your tier’s allowed range comes back as SMS_INVALID_RETENTION with HTTP 400. Decide the number with your DPO, not with a default.
Here’s the exporter that turns those events into an append-only audit line per authentication, in Python 3:
#!/usr/bin/env python3
"""Export SMS authentication events to JSONL for the audit trail."""
import json
import os
import sys
from datetime import datetime, timezone
import requests
BASE = "https://api.infrai.cc"
KEY = os.environ.get("INFRAI_API_KEY")
if not KEY:
sys.exit("INFRAI_API_KEY is not set")
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
def fetch_events(message_id):
resp = SESSION.get(f"{BASE}/v1/sms/events/{message_id}", timeout=15)
payload = resp.json()
if not resp.ok or payload.get("ok") is False:
code = payload.get("error", {}).get("code", f"HTTP_{resp.status_code}")
raise RuntimeError(f"events read failed: {code}")
return payload["data"]["items"]
def audit_line(message_id, user_ref, purpose):
events = fetch_events(message_id)
return {
"recorded_at": datetime.now(timezone.utc).isoformat(),
"message_id": message_id,
"user_ref": user_ref,
"purpose": purpose,
"states": [e["type"] for e in events],
"delivered": any(e["type"] == "delivered" for e in events),
}
if __name__ == "__main__":
mid = sys.argv[1] if len(sys.argv) > 1 else "sms_4Hd8nRbW1kYt"
with open("auth-audit.jsonl", "a", encoding="utf-8") as fh:
fh.write(json.dumps(audit_line(mid, user_ref="usr_7Jq3", purpose="step_up")) + "\n")
print("recorded", mid)
Note the user_ref rather than the phone number. Your audit file doesn’t need to be a second copy of your subscriber list.
What it costs to run this properly
Codes are cheap and controls are not, but the numbers are small either way: $0.007475 per SMS and $0.005 per verify call, verified 2026-07-26, with event reads, status reads and suppression checks free but rate-limited. New accounts get $2 of credit. Check the live rate before you build a budget on those:
curl -sS "https://api.infrai.cc/v1/account/balance" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The response carries a per-capability hint with today’s price and how many calls your balance covers. Rates in this market move downward over time, so treat the figures above as a ceiling.
Where this surface falls short for a regulated product
One disclosure matters more than the rest for an EU deployment: the ready SMS vendor is tencent_sms, with Twilio pending, so a phone number you send to is processed outside the EEA and your transfer assessment has to say so. If your DPO won’t accept that, you’d be better off contracting Twilio Verify or Vonage directly and keeping the processor chain inside your existing paperwork. Inbound messages aren’t supported either, so reply-based confirmation flows are out.
For everything below a payment — login step-up, device enrolment, password reset confirmation — SMS OTP on a managed loop is a reasonable control, and the fact that the queue, the error tracker and the retention setting all sit on the same key is what keeps the evidence in one place when someone asks for it.