DEV Community

KasimirBerg5341
KasimirBerg5341

Posted on

Require Password or Fresh OTP Before Sensitive Actions — 6 Security Checks

A password and a fresh one-time password do not have a universal strength ordering. Short answer: require fresh proof from a factor that is independent of the current session and proportionate to the action's risk. For a fintech wire, a freshly entered account password can expose reuse and phishing risk; an OTP can add useful possession evidence, but only if its delivery channel, enrollment, attempt limits, and recovery path are trustworthy. The safer design evaluates six things: factor independence, verifier freshness, transaction binding, retry controls, recovery strength, and an auditable authorization result.

This distinction matters when an account supports social sign-in. A customer who entered through Google or GitHub may have no local password at all, so "enter your password again" is sometimes an impossible state rather than a security control. Forcing password creation at the wire screen also expands the credential surface at exactly the wrong moment. Step-up should instead ask what evidence the service needs before authorizing this particular transfer.

Should You Require a Password or Fresh OTP Before a Sensitive Action?

Start with the compromised-session case. If an attacker already has a valid session cookie, a button labeled "confirm" changes nothing. Requiring the same session again changes nothing either. The new ceremony must demand evidence the stolen session does not contain.

A password can meet that test when the service has a locally managed password, verifies it directly, and does not treat mere recent session activity as reauthentication. It remains a knowledge factor, however, and OWASP warns that passwords are susceptible to reuse and phishing. A fresh OTP can meet the independence test when delivery depends on a separately controlled channel or authenticator. Freshness limits replay time; it does not repair a compromised channel.

That last sentence is the trap. A code delivered into the same compromised browser session is fresh but not independent, while an email code is only as defensible as access to that mailbox and the recovery process behind it. SMS adds well-known threats that NIST addresses by treating PSTN out-of-band authentication as restricted. A time-based OTP generated by an authenticator follows a defined algorithm, but it can still be phished because the user can relay the code. Neither the word "OTP" nor a short expiry supplies phishing resistance.

For a wire, transaction context matters too. A generic code that approves "something" gives weaker evidence than a challenge whose server-side authorization record names the intended operation, account, destination, amount, currency, and expiration. The UI should show those details before approval. The server, not the browser, must compare the authorized values with the values committed to the ledger.

Derive the gate from six constraints

Treat step-up as an authorization state transition, not as an extra login page. I would model the decision record as immutable evidence because mutable flags such as recently_verified=true lose the questions an investigator will ask later: verified how, for which transfer, under which policy, and until when?

The six checks are compact:

  1. Independence: the proof is not already available to someone holding the current session.
  2. Freshness: the verifier issues or accepts proof within a bounded window and consumes single-use challenges atomically.
  3. Binding: the approval covers the exact wire intent, not every sensitive action for an arbitrary period.
  4. Attempts: guessing, resend, and parallel submission are limited and observable.
  5. Recovery: changing the factor cannot be easier than using it; a recent recovery can trigger delay or review under the service's risk policy.
  6. Outcome: the authorization result is recorded without storing the password or OTP itself.

The storage analogy is useful: a challenge is a single-consumer object with a strict compare-and-set boundary. Two workers may receive the same correct OTP at nearly the same instant. If both can change pending to approved, expiry math was never the main problem. Use one transaction to validate the unexpired challenge, mark it consumed, and create the authorization grant; then make wire creation idempotent against that grant.

Here is a deliberately generic policy core. It does not decide how a password is hashed or how an OTP is delivered; those belong behind separately reviewed verifiers.

from dataclasses import dataclass
from datetime import datetime
from enum import Enum


class ProofKind(Enum):
    LOCAL_PASSWORD = "local_password"
    OUT_OF_BAND_OTP = "out_of_band_otp"
    TOTP = "totp"


@dataclass(frozen=True)
class WireIntent:
    intent_id: str
    account_id: str
    destination_id: str
    amount_minor: int
    currency: str


@dataclass(frozen=True)
class VerifiedProof:
    subject_id: str
    kind: ProofKind
    verified_at: datetime
    challenge_id: str
    bound_intent_id: str


def may_authorize_wire(
    intent: WireIntent,
    proof: VerifiedProof,
    session_subject_id: str,
    now: datetime,
    freshness_seconds: int,
) -> bool:
    age = (now - proof.verified_at).total_seconds()
    return (
        proof.subject_id == session_subject_id
        and proof.bound_intent_id == intent.intent_id
        and 0 <= age <= freshness_seconds
    )
Enter fullscreen mode Exit fullscreen mode

This function is intentionally insufficient on its own. The repository must also guarantee that challenge_id is consumed once, the intent cannot be edited after verification, and the final grant cannot authorize another intent. Those are database invariants, not hopeful comments in a controller.

Password versus OTP under the same failure model

The useful comparison is not "old secret versus new code." It is the operational boundary around each proof.

Criterion Fresh local password Fresh OTP
Works after social sign-in Only if a local password was separately enrolled Yes, if an eligible independent channel or authenticator was enrolled
Evidence added beyond a stolen session Knowledge of the password Access to the OTP channel or authenticator
Main failure modes Phishing, reuse, credential stuffing, weak reset Phishing, channel compromise, interception, unsafe re-enrollment
Replay control Server can rate-limit repeated verification Short validity plus atomic single use and rate limits
Transaction binding Must be added by the application Must be added by the application; a fresh code alone is not binding
Recovery dependency Password reset policy Channel replacement and account recovery policy

So which is stronger? If the account already has a well-managed local password and the OTP would arrive in the same compromised context, fresh password verification may add more independent evidence. If the session began through a social identity provider and no local password exists, an independently enrolled OTP is the usable choice between the two. If phishing resistance is required, neither a replayable password nor a manually entered OTP provides it; the architecture needs a verifier designed for phishing resistance, as described by NIST's authenticator assurance guidance.

No label wins by itself.

The limitations are concrete. Password step-up is unsuitable for a social-only account unless the service first enrolls another local credential, which adds lifecycle and recovery burden. OTP step-up is unsuitable when its delivery channel is reachable from the stolen session, when re-enrollment is weak, or when policy requires phishing-resistant proof. In those cases, choose a separately enrolled, phishing-resistant authenticator rather than weakening the wire policy to fit either candidate.

The session policy should remain explicit. Successful step-up ought to rotate the session identifier, invalidate the challenge, and issue a narrowly scoped authorization result. OWASP recommends reauthentication after risk events and session invalidation or rotation after reauthentication. A long-lived global "recent auth" timestamp is tempting because it reduces prompts, but it can let approval for a profile edit leak into a wire operation. Scope and duration are product risk decisions; they should be named in policy and tested, not hidden in middleware defaults.

Failure paths deserve first-class design

Attackers prefer enrollment and recovery because teams often scrutinize the happy-path verifier while leaving factor replacement to a generic support flow. A customer who loses an authenticator needs a route back, but immediate replacement followed by an unrestricted wire converts account recovery into a transfer credential. Risk-based review, notifications through an existing trusted channel, and a policy-defined hold can reduce that exposure without pretending every failed challenge is malicious.

Availability pulls in the opposite direction. OTP delivery can be delayed, a provider can be unavailable, and customers can travel without their usual number. Password verification avoids delivery dependency but is unavailable to social-only accounts. The practical design therefore separates "which proof is acceptable" from "which proof is available" and fails closed for the wire authorization itself, while preserving a clear recovery path that does not silently downgrade assurance.

Observe the boundary without collecting secrets. Useful events include challenge issued, verification succeeded or failed, challenge expired, attempts exhausted, factor changed, grant consumed, and policy version selected. Correlate them with pseudonymous subject, session, intent, and challenge identifiers. Never log an OTP, password, recovery code, session cookie, or full financial destination.

Tests should attack races and state transitions, not just form validation. Submit the same valid code concurrently and assert that one authorization grant exists. Edit the amount after verification and assert rejection. Reuse a consumed grant, cross it between two sessions, advance the clock past expiry, exhaust attempts, cancel the intent, and replace the enrolled factor. Then test a social-only account so a hidden dependency on password_hash cannot reach production.

Roll out the stronger decision, not a louder prompt

Begin by recording policy decisions without blocking transfers: which proof would have been requested, whether the account could satisfy it, and which recovery path would result. This exposes social-only accounts and enrollment gaps before enforcement. Keep secret values out of telemetry.

Next, enforce step-up for a narrow, well-defined wire cohort with idempotent intent creation and atomic challenge consumption. Track completion, lockout, delivery failure, recovery entry, and authorization reuse attempts. Friction belongs in that review because customers abandoning a transfer is an operational result, but lower friction cannot compensate for proof that is available to a session thief.

Finally, widen enforcement by policy version and retain a fast rollback of the policy decision, not a bypass that approves without evidence. The durable rule is straightforward: choose the verifier whose evidence is independent of the active session, bind it to the exact wire, and make both challenge consumption and grant use single-shot. Password versus OTP is secondary to those invariants.

Sources

Top comments (0)