Implementing Step-Up Authentication for High-Impact DSRs

Within the broader Identity Verification & Attestation framework, this page builds the concrete high-impact path: the code that stops an erasure or portability request from executing until the requester has cleared a second authentication factor and, where warranted, proven identity with a short-lived government-ID check. The engineers who need this are privacy and platform teams whose intake already accepts requests but treats every one with the same trust level — a dangerous default, because an attacker who guesses a session or spoofs an email can trigger an irreversible deletion of someone else’s data. Step-up authentication raises the proof bar exactly for the operations where a wrong disclosure or deletion cannot be undone, while leaving low-impact requests fast. It sits one stage below the parent DSR Architecture & Intake Routing control plane and assumes a signed, schema-valid payload from the sibling Secure Intake Form Design.

The implementation flags high-impact requests, issues a TOTP step-up, accepts an ephemeral ID artifact, hashes it and purges the raw bytes, then records an attestation and emits the event that starts the statutory clock:

Step-up authentication flow for a high-impact DSR A high-impact DSR such as an erasure or portability request enters a risk classifier. If the request is not high impact it diverts to the standard tier. If it is high impact, the pipeline issues a time-based one-time passcode challenge using pyotp. An invalid code diverts to reject or retry; a verified code advances to an ephemeral government-ID upload. The uploaded artifact is hashed with SHA-256 and its raw bytes are purged. Finally the pipeline records an attestation record and emits the attestation event that starts the statutory clock. High-impact DSRerasure / portability Risk classifier — Tier 2 TOTP step-up (pyotp) Ephemeral gov-ID upload Hash (SHA-256),purge raw bytes AttestationRecord+ emit event Standard tier Reject / retry not high impact TOTP invalid verified
The step-up path: high-impact requests clear a TOTP challenge, present an ephemeral ID that is hashed and discarded, and produce an attestation event — non-high-impact and failed-TOTP requests divert off the path.

Prerequisites

This implementation targets Python 3.11+ (for tuple[...] generics without from __future__ and the current datetime timezone API). Install the following:

  • pydantic (v2) — strict validation of inputs and the immutable AttestationRecord. The code uses v2 idioms (model_config = ConfigDict(...), field_validator); v1 patterns (class Config, @validator) will not work.
  • pyotp — RFC 6238 time-based one-time passcodes for the step-up factor.
  • hashlib (stdlib) — SHA-256 digest of the verification artifact, a NIST SP 800-107 approved function.
  • cryptography (optional) — for provisioning and encrypting the per-user TOTP secret at rest; the secret must never be stored in plaintext.

The TOTP shared secret is enrolled once per subject through a trusted channel and stored encrypted. This page owns only the step-up decision and attestation; it hands the attested request forward to the routing stage exactly as the parent cluster specifies.

Step-by-Step Implementation

Step 1 — Classify the request and require step-up

The classifier is a pure function that flags erasure and portability — the irreversible, high-disclosure operations — as high impact, along with anything touching special-category data. GDPR Art. 12(6) permits requesting additional information to confirm identity where the controller has reasonable doubts, and treating these request types as high impact is how that discretion becomes a consistent rule rather than a case-by-case judgement.

from enum import IntEnum

from pydantic import BaseModel, ConfigDict, Field


class Tier(IntEnum):
    STANDARD = 1
    HIGH_IMPACT = 2


HIGH_IMPACT_TYPES = frozenset({"erasure", "portability"})


class StepUpRequest(BaseModel):
    """Validated input to the step-up decision."""

    model_config = ConfigDict(strict=True, frozen=True)

    request_id: str = Field(pattern=r"^dsr_[0-9a-f]{32}$")
    request_type: str = Field(min_length=3, max_length=32)
    special_category: bool = False  # Art. 9 data in scope


def classify(req: StepUpRequest) -> Tier:
    """Flag erasure/portability and Art. 9 requests as high impact."""
    if req.request_type in HIGH_IMPACT_TYPES or req.special_category:
        return Tier.HIGH_IMPACT
    return Tier.STANDARD

A Tier.HIGH_IMPACT result means the request cannot proceed on session trust alone; it must clear the TOTP factor and the artifact check in the following steps.

Step 2 — Issue and verify the TOTP step-up

The step-up factor is a time-based one-time passcode bound to the subject’s enrolled secret. Using pyotp, verification allows a small window for clock drift but no more, so a stale code cannot be replayed minutes later. The secret is loaded from encrypted storage per request and never logged.

import pyotp


def verify_totp(enrolled_secret: str, submitted_code: str) -> bool:
    """Verify a submitted TOTP against the subject's enrolled secret.

    `valid_window=1` tolerates one 30-second step of clock drift on either
    side; widening it weakens the factor, so keep it tight.
    """
    totp = pyotp.TOTP(enrolled_secret)
    return totp.verify(submitted_code, valid_window=1)

A failed verification never advances the request. The pipeline returns the requester to the challenge (subject to a retry cap) or rejects the attempt, and it records the failure — but it does not start the statutory clock, because identity is not yet attested.

Step 3 — Accept an ephemeral artifact, hash it, purge the raw bytes

For the highest-assurance requests, TOTP is paired with a government-ID check. The critical discipline is that the document is ephemeral: it is compared to the account of record, a decision is recorded, and the raw bytes are hashed and discarded. Retaining the image would itself violate GDPR Art. 5(1)© data minimization, and an ID image is Art. 9 special-category-adjacent data, so keeping it converts a verification step into a standing liability.

import hashlib
from datetime import datetime, timezone

from pydantic import BaseModel, ConfigDict, Field


class AttestationRecord(BaseModel):
    """The only durable output of verification — no raw artifact retained."""

    model_config = ConfigDict(strict=True, frozen=True)

    request_id: str = Field(pattern=r"^dsr_[0-9a-f]{32}$")
    tier: int = Field(ge=1, le=2)
    method: str = Field(min_length=3, max_length=48)
    artifact_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
    matched: bool
    attested_at: datetime


def attest_artifact(request_id: str, artifact: bytes, matched: bool) -> AttestationRecord:
    """Hash the ephemeral ID artifact, then let its raw bytes fall out of scope.

    `artifact` is passed in memory from the ephemeral upload buffer. It is
    hashed here and must not be written to disk, cache, or logs anywhere.
    """
    digest = hashlib.sha256(artifact).hexdigest()
    return AttestationRecord(
        request_id=request_id,
        tier=int(Tier.HIGH_IMPACT),
        method="totp+gov_id",
        artifact_sha256=digest,
        matched=matched,
        attested_at=datetime.now(timezone.utc),
    )

The AttestationRecord carries the hash and the match decision — enough to prove an artifact was checked, and nothing that reconstructs the document. The upload buffer that held artifact is a short-TTL ephemeral store; once this function returns, the raw bytes exist nowhere in the system.

Step 4 — Emit the attestation event and start the clock

A successful attestation is the event that anchors every downstream deadline. GDPR Art. 12(3) sets a one-month response window, but Art. 12(6) lets the controller pause for identity confirmation, so the clock starts at attestation, not submission. The event is emitted to the audit layer with the hash and decision — never the raw artifact.

def run_step_up(req: StepUpRequest, enrolled_secret: str, submitted_code: str,
                artifact: bytes, id_matches: bool, emit) -> AttestationRecord | None:
    """Drive the full high-impact step-up and emit the attestation event.

    `emit` is an audit sink callable. Returns the AttestationRecord on success,
    or None if the request is not high impact or the TOTP fails.
    """
    if classify(req) is not Tier.HIGH_IMPACT:
        return None  # standard tier handled elsewhere
    if not verify_totp(enrolled_secret, submitted_code):
        emit({"request_id": req.request_id, "event": "attestation_failed",
              "reason": "totp_invalid"})
        return None
    record = attest_artifact(req.request_id, artifact, id_matches)
    if not record.matched:
        emit({"request_id": req.request_id, "event": "attestation_failed",
              "reason": "id_mismatch"})
        return None
    emit({"request_id": record.request_id, "event": "attested",
          "tier": record.tier, "method": record.method,
          "artifact_sha256": record.artifact_sha256,
          "attested_at": record.attested_at.isoformat()})
    return record

The emitted attested event flows to the Audit Logging & Compliance Evidence layer, and its timestamp is what the SLA stage anchors the deadline to. Note that no branch of run_step_up ever puts the raw artifact into the audit sink.

Configuration Reference

Parameter Type Default Compliance note
high_impact_types frozenset[str] {"erasure", "portability"} The irreversible operations that trigger step-up under Art. 12(6) reasonable-doubt discretion.
special_category bool False When true, escalates to Tier 2 regardless of type — Art. 9 data warrants the strongest proof.
totp_valid_window int 1 Steps of 30-second drift tolerated; wider windows weaken the factor and invite replay.
totp_digits int 6 RFC 6238 default; keep at 6 unless enrollment provisions otherwise.
artifact_hash str sha256 NIST SP 800-107 approved function; BLAKE3 is an acceptable faster alternative.
artifact_buffer_ttl_s int 120 Ephemeral upload lifetime; bounds Art. 5(1)© exposure of the raw ID image.
totp_retry_cap int 5 Max TOTP attempts before lockout, to blunt brute-force of the step-up factor.
retain_raw_artifact bool False Must stay false — retaining the ID document violates Art. 5(1)© data minimization.

Verification

Two assertions matter most: that a valid TOTP verifies and an invalid one does not, and that no raw ID artifact survives attestation. The first proves the factor works; the second proves the data-minimization guarantee.

import hashlib

import pyotp


def test_totp_verifies_current_code():
    secret = pyotp.random_base32()
    code = pyotp.TOTP(secret).now()
    assert verify_totp(secret, code) is True
    assert verify_totp(secret, "000000") is False


def test_raw_artifact_is_not_retained():
    artifact = b"fake-id-image-bytes"
    req = StepUpRequest(request_id="dsr_" + "a" * 32, request_type="erasure")
    record = attest_artifact(req.request_id, artifact, matched=True)
    # Only the hash survives; the serialized record never carries raw bytes.
    assert record.artifact_sha256 == hashlib.sha256(artifact).hexdigest()
    assert "fake-id-image-bytes" not in record.model_dump_json()
    assert record.matched is True

Expect a passing suite to confirm three things: classify sends erasure and portability to Tier 2, verify_totp accepts only a live code, and attest_artifact yields a record whose serialization contains the hash but never the source bytes. Run the non-retention assertion in CI so a future refactor that “temporarily” stashes the image fails the build rather than shipping a violation.

Troubleshooting

TOTP always fails despite the user entering the right code — Root cause: server/device clock skew beyond the validation window, or a mismatched enrolled secret. Fix: sync the server clock via NTP, confirm the stored secret matches enrollment, and keep valid_window=1 rather than widening it to mask the drift.

Raw ID image appears in logs or object storage — Root cause: the upload was written or logged before hashing, or a debug handler captured the request body. Fix: hash in the same scope the bytes arrive, remove any body-logging middleware on the upload route, and assert non-retention in CI as shown above — this is an Art. 5(1)© violation, not a cosmetic bug.

Standard requests are being forced through step-up — Root cause: classify over-matching, or special_category set spuriously. Fix: verify HIGH_IMPACT_TYPES contains only erasure and portability and that the special-category flag is derived from actual data scope, so low-impact requests stay fast per the Art. 12(2) duty to facilitate rights.

The SLA clock starts before identity is proven — Root cause: the deadline is anchored to submission instead of the attested event. Fix: start the clock only from AttestationRecord.attested_at on a matched record, consistent with the parent Identity Verification & Attestation stage and Art. 12(6).

TOTP endpoint abused for brute force — Root cause: no retry cap or rate limit on code submission. Fix: enforce totp_retry_cap lockout per request and rate-limit issuance per account so the step-up factor cannot be guessed or used as an oracle.

Frequently Asked Questions

Why require step-up only for erasure and portability, not every request?

Because those operations are irreversible or high-disclosure, and GDPR Art. 12(6) scopes additional identity proof to cases of reasonable doubt while Art. 12(2) requires controllers to facilitate rights. Forcing step-up on a routine access or opt-out over-collects proof and slows lawful requests. Reserving it for high-impact types keeps the strong factor where a wrong action cannot be undone.

Why hash the ID document instead of storing it as proof?

Storing the document violates GDPR Art. 5(1)© data minimization, and an ID image is Art. 9 special-category-adjacent data that raises the risk further. Hashing with a NIST SP 800-107 approved function such as SHA-256 records that an artifact was checked and matched, without keeping anything that reconstructs the original. The hash is the defensible evidence; the image is a liability.

How wide should the TOTP validation window be?

As narrow as clock drift allows — valid_window=1 tolerates one 30-second step on either side. Widening it lets a captured code be replayed later, weakening the second factor. If codes fail legitimately, fix server time via NTP rather than loosening the window.

When does the statutory clock start in this flow?

At the attested event emitted after a matched attestation, not at submission. GDPR Art. 12(3) sets the one-month window but Art. 12(6) permits pausing for identity confirmation, so the defensible anchor is AttestationRecord.attested_at. The event flows to the audit layer and the SLA stage computes the deadline from that timestamp.