Payment Security Standards · Part 5 of 8
ASVS 5.0 Authentication in a Payments API: TOTP, Argon2id, Tokens
Published 10 min readBy Saeed Yari

On this page
The admin and merchant console of a payments API is where one stolen password buys the most: refunds, payout details, API keys. The OWASP® Application Security Verification Standard (ASVS) 5.0 gives you a testable list of what sign-in must do, and PCI DSS v4.0.1 Requirement 8 adds rules of its own, a few of which pull the other way. This is the design I would ship, with Python 3.13 code, versioned ASVS IDs and the places where the two standards must be reconciled.
A payments admin API should target at least ASVS Level 2 (see Part 3). At L2, multi-factor authentication (MFA) is mandatory under v5.0.0-6.3.3.
On the PCI side, Requirement 8 sets password length, lockout and rotation rules (8.3.x), demands MFA for access into the cardholder data environment (8.4.1, 8.4.2), and says an MFA system must resist replay and bypass (8.5.1). Whether merchant users access the CDE is a scoping question for your assessor (Part 2); the simplest way out of that debate is MFA for every account.
On a multi-tenant payment aggregator I work on, I specified and built admin and merchant sign-in test-first, with TOTP 2FA, Argon2id password hashing, EdDSA-signed JWTs, refresh-token reuse detection under row locks, CSRF and Origin checks, and per-account and per-IP backoff. Around it sit role-based access control, an append-only audit trail and AES-256-GCM envelope encryption for secrets. The code here is a generic rebuild of those patterns, not that system's code.
Scroll sideways to see the whole diagram. Open full size
v5.0.0-11.4.2 requires a computationally intensive password hashing function with parameters set from current guidance. ASVS Appendix C gives Argon2id minimums as trade-offs between memory and iterations: 46 MiB with t=1, 19 MiB with t=2, or 12 MiB with t of 3 or more, all with p=1. The OWASP Password Storage Cheat Sheet recommends the same 19 MiB, t=2, p=1 floor.
Still, name your source, because they differ elsewhere: for PBKDF2-HMAC-SHA-512, Appendix C says 210,000 iterations and the Cheat Sheet says 220,000. The argon2-cffi default (RFC 9106 low-memory profile: 64 MiB, t=3, p=4) is above the floor; choose by measuring login latency and memory under peak concurrent sign-ins.
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
# Floor from ASVS 5.0 Appendix C and the OWASP Password Storage Cheat Sheet
ph = PasswordHasher(time_cost=2, memory_cost=19 * 1024, parallelism=1) # KiB
DUMMY_HASH = ph.hash("dummy-password-for-unknown-users")
def verify_password(stored: str | None, password: str) -> tuple[bool, str | None]:
"""Return (ok, upgraded_hash). Unknown users still cost one hash."""
try:
ph.verify(stored or DUMMY_HASH, password)
except VerifyMismatchError:
return False, None
if stored is None:
return False, None
return True, ph.hash(password) if ph.check_needs_rehash(stored) else None
The dummy hash keeps timing similar for unknown users, against enumeration (v5.0.0-6.3.8, L3 but free). check_needs_rehash handles parameter upgrades at the next login.
ASVS asks for at least 8 characters with 15 strongly recommended (v5.0.0-6.2.1), no composition rules (v5.0.0-6.2.5), at least 64 characters allowed (v5.0.0-6.2.9), a check against common and breached passwords (v5.0.0-6.2.4, v5.0.0-6.2.12), and no truncation (v5.0.0-6.2.8). PCI DSS 8.3.6 asks for at least 12 characters with both letters and digits and, as written, applies whenever passwords are used as an authentication factor, MFA or not.
Length is easy: 12 meets both minimums, and 15 also follows the ASVS recommendation. The letters-and-digits rule is a composition rule, and no reading of v5.0.0-6.2.5 makes it pass. For accounts in PCI scope, enforce that one rule and nothing else, record it as a documented deviation from v5.0.0-6.2.5 with the reason, and don't claim v5.0.0-6.2.5 for those accounts. The other route is PCI DSS's customized approach for 8.3.6, which needs its own targeted risk analysis and is meant for organizations with mature risk management (Part 1). For one digit in a passphrase, that is rarely worth it.
from collections.abc import Callable
MIN_LEN, MAX_LEN = 12, 128 # PCI DSS 8.3.6 floor; ASVS needs at least 64 allowed
def password_problems(pw: str, is_breached: Callable[[str], bool]) -> list[str]:
problems = []
if not MIN_LEN <= len(pw) <= MAX_LEN:
problems.append(f"Use {MIN_LEN} to {MAX_LEN} characters.")
# The only composition rule, kept because PCI DSS 8.3.6 requires it.
if not (any(c.isalpha() for c in pw) and any(c.isdigit() for c in pw)):
problems.append("Include at least one letter and one number.")
if is_breached(pw):
problems.append("This password appears in known breaches.")
return problems
A time-based one-time password (TOTP, RFC 6238) is a short code derived from a shared secret and the current 30-second time step. V6.5 requires that each code works only once (v5.0.0-6.5.1), that seeds come from a cryptographically secure random generator (v5.0.0-6.5.3), that a code lives at most 30 seconds (v5.0.0-6.5.5), and that the server checks it against trusted time, not client time (v5.0.0-6.5.8, L3). PCI DSS 8.5.1 asks for an MFA system that is not susceptible to replay, and RFC 6238 section 5.2 already forbids accepting the same code twice.
pyotp does not track used codes, so store the last accepted time step per user and claim it atomically:
import hmac
import re
import time
import pyotp
STEP = 30 # seconds: the whole lifetime of a code
def verify_totp(db, user_id: int, secret: str, code: str) -> bool:
if not re.fullmatch(r"[0-9]{6}", code):
return False
step = int(time.time()) // STEP # server clock (NTP), never client time
expected = pyotp.TOTP(secret, interval=STEP).generate_otp(step)
if not hmac.compare_digest(code.encode(), expected.encode()):
return False
# Claim the step atomically: a replay in the same step updates no row.
claimed = db.execute(
"UPDATE users SET totp_last_step = %s WHERE id = %s"
" AND (totp_last_step IS NULL OR totp_last_step < %s) RETURNING id",
(step, user_id, step),
).fetchone()
return claimed is not None
The conditional UPDATE is a compare-and-set, so two concurrent requests with the same code cannot both win. Three more decisions:
- Window. RFC 6238 suggests tolerating one step of network delay, but accepting the previous step lets a code live up to 60 seconds, past v5.0.0-6.5.5. Accept the current step only; if you must allow drift, document it as a deviation.
- Brute force. Six digits is a million combinations, so TOTP failures count toward the same per-account limit as password failures.
- Seeds.
pyotp.random_base32()returns 160 bits fromsecrets.SystemRandom, matching the HMAC-SHA-1 output length RFC 6238 recommends. Store seeds encrypted at rest, show the QR code once, and require full re-authentication before changing MFA settings (v5.0.0-7.5.1).
A JSON Web Token (JWT) is a signed set of claims the API can check without a database call. Sign with Ed25519 (EdDSA, approved in Appendix C), keep lifetimes short (five minutes here), and mark the token type with typ: at+jwt from RFC 9068. Verification follows V9: signature before contents (v5.0.0-9.1.1), an algorithm allowlist (v5.0.0-9.1.2), keys from configuration rather than token headers (v5.0.0-9.1.3), exp and nbf (v5.0.0-9.2.1), token type (v5.0.0-9.2.2) and audience (v5.0.0-9.2.3).
import secrets
from datetime import UTC, datetime, timedelta
import jwt # PyJWT, installed with the "crypto" extra
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
ISSUER, AUDIENCE = "https://auth.example.com", "merchant-api"
signing_key = Ed25519PrivateKey.generate() # production: load from a secrets manager
public_key = signing_key.public_key()
def issue_access_token(user_id: str, roles: list[str]) -> str:
now = datetime.now(UTC)
claims = {"iss": ISSUER, "aud": AUDIENCE, "sub": user_id, "roles": roles,
"iat": now, "nbf": now, "exp": now + timedelta(minutes=5),
"jti": secrets.token_urlsafe(16)}
return jwt.encode(claims, signing_key, algorithm="EdDSA",
headers={"typ": "at+jwt"})
def verify_access_token(token: str) -> dict:
typ = jwt.get_unverified_header(token).get("typ")
if typ not in ("at+jwt", "application/at+jwt"): # RFC 9068 token type
raise jwt.InvalidTokenError("not an access token")
return jwt.decode(token, public_key, algorithms=["EdDSA"],
audience=AUDIENCE, issuer=ISSUER,
options={"require": ["exp", "nbf", "iat", "sub"]})
A self-contained token stays valid until exp; v5.0.0-7.4.1 suggests a deny list, a per-user "not before" time or per-user keys. A short lifetime bounds the window, and a cached per-user cut-off handles disabled accounts.
A refresh token is a long-lived, opaque credential that buys new access tokens. All tokens descending from one sign-in form a family. v5.0.0-10.4.5 is written for OAuth authorization servers, but a sign-in API that issues refresh tokens plays that role. For public clients it prefers sender-constrained tokens (DPoP or mTLS) and allows rotation at L1 and L2: invalidate each token after use, and revoke every refresh token of that authorization when a used one comes back. RFC 9700 section 4.14.2 explains why: the server cannot tell whether the attacker or the client presented the old token, so it revokes the active one. v5.0.0-10.4.8 adds an absolute expiry even with sliding renewal.
Scroll sideways to see the whole diagram. Open full size
CREATE TABLE refresh_tokens (
id bigserial PRIMARY KEY,
token_hash bytea NOT NULL UNIQUE, -- SHA-256 of the token
family_id uuid NOT NULL, -- indexed; one per sign-in
user_id bigint NOT NULL,
expires_at timestamptz NOT NULL, -- min(idle, family limit)
family_expires_at timestamptz NOT NULL, -- absolute, set at sign-in
used_at timestamptz,
revoked_at timestamptz
);
import hashlib
import secrets
from datetime import UTC, datetime, timedelta
IDLE = timedelta(minutes=15)
class Unauthorized(Exception): ...
def digest(token: str) -> bytes:
return hashlib.sha256(token.encode()).digest() # 256-bit random input
def rotate(conn, presented: str) -> str:
"""conn: psycopg 3 connection with row_factory=namedtuple_row."""
now = datetime.now(UTC)
with conn.transaction(): # commits on normal exit
row = conn.execute("SELECT * FROM refresh_tokens"
" WHERE token_hash = %s FOR UPDATE",
(digest(presented),)).fetchone()
if row is None:
outcome = "unknown"
elif row.used_at or row.revoked_at: # a rotated token came back
conn.execute("UPDATE refresh_tokens SET revoked_at = %s"
" WHERE family_id = %s AND revoked_at IS NULL",
(now, row.family_id))
outcome = "reuse"
elif now >= row.expires_at:
outcome = "expired"
else:
new = secrets.token_urlsafe(32)
conn.execute("UPDATE refresh_tokens SET used_at = %s WHERE id = %s",
(now, row.id))
conn.execute("INSERT INTO refresh_tokens (token_hash, family_id,"
" user_id, expires_at, family_expires_at)"
" VALUES (%s, %s, %s, %s, %s)",
(digest(new), row.family_id, row.user_id,
min(now + IDLE, row.family_expires_at),
row.family_expires_at))
outcome = "rotated"
if outcome != "rotated":
raise Unauthorized(outcome) # log "reuse" as a security event
return new
The exception is raised after the commit; raising inside the block would roll the revocation back. Without FOR UPDATE, two requests can both read an unused row and mint two children, forking the family so reuse is never seen. With the lock, under PostgreSQL's default READ COMMITTED, the second request waits, re-reads the row, sees used_at, and revokes the family, including the child just created.
The trade-off: a client that fires two refreshes in parallel logs itself out. Fix that on the client with a single in-flight refresh or a backend-for-frontend; a grace period that hands back the same child weakens detection. Refresh only on user activity, or a background timer defeats the 15-minute idle limit that PCI DSS 8.2.8 sets for sessions.
Cross-site request forgery (CSRF) is a forged request from another site that rides on credentials the browser attaches by itself, usually cookies. In a pure bearer-token API, your code puts the token in an Authorization header, so a forged request carries no credential and CSRF does not apply. Tokens held in JavaScript are exposed to XSS instead; v5.0.0-10.1.1 says that with a backend-for-frontend, only the backend should hold access and refresh tokens. Admin consoles often run that way with a session cookie, and then CSRF applies, as it does to any refresh endpoint that reads a cookie.
For the cookie, V3.3 asks for the __Host- prefix (v5.0.0-3.3.3), which forces Secure, Path=/ and no Domain, plus HttpOnly (v5.0.0-3.3.4) and a SameSite value chosen for the cookie's purpose (v5.0.0-3.3.2):
Set-Cookie: __Host-session=<opaque>; Path=/; Secure; HttpOnly; SameSite=Strict
SameSite alone is not enough: it is scoped to the registrable domain, so a sibling subdomain counts as same-site, and the CSRF Prevention Cheat Sheet treats it as defense in depth. V3.5 asks you to validate that sensitive requests come from your application: with an anti-forgery token or a non-safelisted header (v5.0.0-3.5.1), with Origin and Content-Type checks if you rely on CORS preflight (v5.0.0-3.5.2), and without state changes on GET (v5.0.0-3.5.3).
import hmac
from collections.abc import Mapping
ALLOWED_ORIGINS = {"https://admin.example.com"}
UNSAFE = {"POST", "PUT", "PATCH", "DELETE"}
def csrf_ok(method: str, headers: Mapping[str, str], session_csrf: str) -> bool:
if method not in UNSAFE:
return True # so GET handlers must never change state
if headers.get("sec-fetch-site") == "cross-site":
return False
if headers.get("origin") not in ALLOWED_ORIGINS: # exact match; "null" fails
return False
sent = headers.get("x-csrf-token", "")
return hmac.compare_digest(sent.encode(), session_csrf.encode())
session_csrf is a per-session token stored server-side (the synchronizer token pattern). If some clients omit Origin, fall back to the origin of Referer, and treat a missing header as a failure. The custom header also forces a CORS preflight, so keep your CORS allowlist exact.
v5.0.0-6.1.1 asks you to document how rate limiting stops credential stuffing and brute force without enabling malicious account lockout; v5.0.0-6.3.1 asks you to implement it. PCI DSS 8.3.4 caps invalid attempts at ten before the user ID is locked, and the lock must last 30 minutes or more unless the user's identity is confirmed first.
That is the tension: merchant login emails are often easy to find, and ten requests lock any account. Under 8.3.4 you cannot remove that lever, but you can make it visible and easy to recover from:
- A per-IP throttle slows stuffing across many accounts without counting against any of them.
- Per-account backoff starts at the third failure, and the lock at the tenth. Lifting the lock early needs a reset that also requires TOTP, since resets must not bypass MFA (v5.0.0-6.4.3).
- Locked, unknown and wrong-password responses look identical, and a burst of lockouts raises an alert.
from datetime import datetime, timedelta
LOCK_AFTER, LOCK_FOR = 10, timedelta(minutes=30) # PCI DSS 8.3.4 limits
def next_attempt_at(failures: int, now: datetime) -> datetime | None:
"""failures: consecutive password or TOTP failures, reset on full success."""
if failures >= LOCK_AFTER:
return now + LOCK_FOR # lifted early only by a reset that needs TOTP
if failures >= 3:
return now + timedelta(seconds=2 ** (failures - 3)) # 1, 2, 4 ... 64 s
return None
Password rotation is the conflict that disappears. v5.0.0-6.2.10 forbids periodic rotation. PCI DSS 8.3.9 requires a 90-day change, or dynamic risk analysis, only where a password is the sole factor. PCI SSC FAQ 1591 says that if MFA covers all in-scope components, the assessor can mark 8.3.9 and 8.3.10.1 not applicable.
| PCI DSS v4.0.1 | ASVS 5.0.0 | How to reconcile them |
|---|---|---|
| 8.3.6: 12+ characters, letters and digits | v5.0.0-6.2.1 length; v5.0.0-6.2.5 no composition rules | 12-character minimum; letter plus digit is the only rule; documented deviation from v5.0.0-6.2.5 for in-scope accounts |
| 8.3.4: lock after 10 failures, 30+ minutes | v5.0.0-6.1.1, v5.0.0-6.3.1: anti-automation without malicious lockout | Per-IP throttle, per-account backoff, lock at 10, lifted early only by a reset with TOTP |
| 8.3.9, 8.3.10.1: 90-day change for single-factor passwords | v5.0.0-6.2.10: no periodic rotation | MFA on every account, so rotation rules do not apply; change on evidence of compromise |
| 8.4.1, 8.4.2: MFA into the CDE | v5.0.0-6.3.3: MFA at L2 | TOTP for every admin and merchant user |
| 8.5.1: MFA not replayable or bypassable | v5.0.0-6.5.1, v5.0.0-6.5.5 | Atomic last-step claim; no API token before both factors pass |
| 8.2.8: re-authenticate after 15 idle minutes | v5.0.0-7.3.1, v5.0.0-7.3.2: documented idle and absolute limits | 15-minute refresh idle limit plus an absolute family lifetime (for example 12 hours) |
| 8.3.2: factors unreadable in storage | v5.0.0-11.4.2: password hashing per current guidance | Argon2id at the Appendix C floor; TOTP seeds encrypted; refresh tokens stored as hashes |
On the aggregator, sign-in was specified before it was built: 40 requirements and 96 test scenarios, mapped to 13 ASVS 5.0 controls. Every ID in this article converts the same way: write at least one negative test first, and run concurrency tests against real PostgreSQL, since SQLite has no row locks.
| Requirement | Test scenario |
|---|---|
| v5.0.0-6.2.5, 8.3.6 | correct horse battery 42 passes; abcdefghijkl fails only for the missing digit |
| v5.0.0-6.5.1, v5.0.0-6.5.5 | A code reused in its step, or from the previous step, fails |
| v5.0.0-9.1.2 | Tokens with alg: none or HS256 are rejected |
| v5.0.0-10.4.5 | Replaying a rotated refresh token revokes its whole family |
| v5.0.0-10.4.8 | Refresh fails after the family's absolute expiry, even if active |
| v5.0.0-3.5.1 | A POST with a foreign Origin or no CSRF token gets 403 |
| 8.3.4 | The 11th attempt with the right password still fails while locked |
The race test is the one that catches a missing lock:
def test_parallel_refreshes_cannot_fork_the_family(auth):
rt = auth.sign_in(USER, PASSWORD, totp_now(USER)).refresh_token
with ThreadPoolExecutor(max_workers=2) as pool:
outcomes = sorted(pool.map(auth.try_refresh, [rt, rt]))
assert outcomes == ["reuse", "rotated"]
assert auth.active_refresh_tokens(USER) == 0
- Require MFA for every admin and merchant account: it meets v5.0.0-6.3.3 and PCI DSS 8.4.2 and retires the 90-day rotation rule.
- Set Argon2id to at least the Appendix C floor, with rehash-on-login.
- Add the last-step claim to TOTP verification and count TOTP failures toward lockout.
- Put refresh-token rotation under
SELECT ... FOR UPDATE, revoke the family on reuse, and add an absolute expiry. - Document the two deliberate trade-offs, the composition rule and the lockout design, for both your assessor and your ASVS verifier.
FAQ
Does CSRF protection apply to an API that only uses bearer tokens?
Not when your own code attaches the token in an Authorization header, because the browser never sends it on its own. It applies as soon as any credential, including a refresh token, travels in a cookie.
Can I accept the previous TOTP code to tolerate clock drift?
RFC 6238 allows one step of tolerance, but that stretches a code's life past the 30 seconds in ASVS v5.0.0-6.5.5. If you accept it, record it as a documented deviation and keep server clocks on NTP.
Does PCI DSS require Argon2id?
No. PCI DSS asks for strong cryptography that makes authentication factors unreadable in storage (Requirement 8.3.2). Argon2id is one way to meet that, and ASVS 5.0 lists it as an approved password hashing function.
Do I still need 90-day password changes under PCI DSS 4.0.1?
Only where a password is the only factor. If every in-scope account uses MFA, a PCI SSC FAQ says the assessor can mark Requirements 8.3.9 and 8.3.10.1 not applicable.
References
- ASVS 5.0.0 – V6 Authentication · OWASP Foundation (GitHub)
- ASVS 5.0.0 – V7 Session Management · OWASP Foundation (GitHub)
- ASVS 5.0.0 – V9 Self-contained Tokens · OWASP Foundation (GitHub)
- ASVS 5.0.0 – V10 OAuth and OIDC · OWASP Foundation (GitHub)
- ASVS 5.0.0 – V3 Web Frontend Security · OWASP Foundation (GitHub)
- ASVS 5.0.0 – V11 Cryptography · OWASP Foundation (GitHub)
- ASVS 5.0.0 – Appendix C: Cryptography Standards · OWASP Foundation (GitHub)
- PCI DSS v4.0.1 (Document Library) · PCI Security Standards Council
- FAQ 1591: Why do Requirements 8.3.9 and 8.3.10.1 focus on passwords used for single-factor authentication? · PCI Security Standards Council
- PCI DSS v4.0: Is the Customized Approach Right for Your Organization? · PCI Security Standards Council (blog)
- RFC 9700: Best Current Practice for OAuth 2.0 Security · IETF
- RFC 6238: TOTP: Time-Based One-Time Password Algorithm · IETF
- RFC 9068: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens · IETF
- Password Storage Cheat Sheet · OWASP Cheat Sheet Series
- Cross-Site Request Forgery Prevention Cheat Sheet · OWASP Cheat Sheet Series
- Microsoft Entra ID and PCI-DSS Requirement 8 (summarizes the v4.0 requirement text) · Microsoft Learn