Healthtech sign-in should treat an OAuth exchange as a resumable transaction, not a button click. For account deletion under GDPR, the safest policy is to retry only idempotent preparation work, then require a fresh authorization transaction when the callback cannot be proven authentic. That keeps session security ahead of a little user friction.
I use an eval-driven harness for this decision. It injects timeouts, duplicate callbacks, expired state, and provider errors, then checks that no session survives a confirmed deletion. A retry that feels convenient in a demo can become a token replay in production.
The experiment note: where a retry actually belongs
The simple approach is one broad retry wrapper around the whole flow: start authorization, wait for the callback, exchange the code, and try again on any exception. It is easy to explain and hard to secure. Authorization URLs are safe to regenerate, but a callback code is normally single-use. Replaying the exchange after an unknown timeout can produce either a second request or a misleading failure, depending on what the provider already processed.
My chosen boundary is narrower. Give each login attempt a transaction identifier and persist its state before redirecting. Retry the redirect preparation if the database call times out, provided the write is idempotent. Once the browser returns, validate state, issuer, redirect URI, PKCE verifier, and nonce. If any of those checks are inconclusive, stop and start a new transaction. Never silently reuse the old callback code.
Here is a compact Python sketch. The endpoint names are deliberately generic; the important part is the state machine and its audit record.
from dataclasses import dataclass
from hashlib import sha256
import secrets
import time
@dataclass
class AuthAttempt:
attempt_id: str
state_hash: str
pkce_verifier: str
created_at: float
status: str = "prepared"
def prepare_attempt(store) -> AuthAttempt:
attempt_id = secrets.token_urlsafe(18)
state = secrets.token_urlsafe(32)
verifier = secrets.token_urlsafe(48)
attempt = AuthAttempt(
attempt_id=attempt_id,
state_hash=sha256(state.encode()).hexdigest(),
pkce_verifier=verifier,
created_at=time.time(),
)
# Put-if-absent makes a timeout safe to retry with the same attempt_id.
store.put_if_absent(attempt_id, attempt)
return attempt
The real implementation must return the unhashed state only to the browser, keep the verifier server-side, and expire attempts quickly. I test expiry at 300 seconds and also test clock skew; your mileage may vary because provider limits differ. The measurement that matters before copying this design is not login completion alone: record duplicate callback acceptance, invalid-state rejection, and the percentage of deletion workflows that leave an active session.
How should OAuth failure recovery handle safe retries across authorization and callback steps?
Separate the flow into observable phases:
-
prepared: state, nonce, PKCE verifier, and an attempt record exist. -
redirected: the authorization request was rendered; no credential has been issued yet. -
callback_received: query parameters arrived, but they are untrusted input. -
exchanged: the code was accepted and tokens were obtained. -
revoked: local sessions and refresh credentials are invalidated.
Only phases one and two are naturally retryable. A failed database write can be retried with an idempotency key. A failed page render can be regenerated from the stored attempt. Phase four is different: if the token endpoint times out, mark the attempt as exchange_unknown, retain an audit trail, and ask for a fresh authorization. That extra click is preferable to guessing whether a one-time code was consumed.
Callback handling should be boring and strict. Compare the returned state against the stored hash using a constant-time comparison. Check that the authorization response belongs to the expected issuer and client, then exchange the code once with the stored PKCE verifier. Treat error=access_denied as a user decision, not a transient transport failure. Treat network timeout as unknown outcome, not permission to replay.
In Python, the exchange boundary can expose that distinction to the rest of the application:
class ExchangeUnknown(Exception):
pass
async def exchange_once(provider, attempt, code):
if attempt.status != "callback_received":
raise ValueError("attempt is not exchangeable")
try:
token = await provider.exchange_code(
code=code,
code_verifier=attempt.pkce_verifier,
)
except TimeoutError as exc:
attempt.status = "exchange_unknown"
raise ExchangeUnknown from exc
attempt.status = "exchanged"
return token
A queue can retry the follow-up cleanup, but it should carry the attempt ID and an idempotency key. The worker must be safe to run twice: revoke the local session, delete refresh-token references, and emit one logically unique audit event. Do not put raw authorization codes or access tokens in logs.
Account deletion changes the retry policy
For a GDPR deletion request, authentication is only the first gate. The deletion command should require a recently verified identity, record the request, revoke every session, and make the operation repeatable. If the browser loses connection after the server accepts the request, a second submission should return the same request status rather than create a second workflow.
A useful contract is:
async def delete_account(account_id, request_key, db, sessions, audit):
request = await db.get_or_create_deletion_request(
account_id=account_id,
request_key=request_key,
)
if request.completed_at:
return request
await sessions.revoke_all(account_id)
await db.remove_refresh_credentials(account_id)
await audit.record_once(
key=request_key,
event="account_deleted",
account_id=account_id,
)
return await db.mark_deletion_completed(request.id)
The catch is that revocation semantics differ across identity providers. Local sessions can be revoked immediately; an already issued access token may remain valid until its short lifetime ends unless the resource server checks a revocation list or token version. Document that boundary to privacy and support teams. If your system cannot guarantee prompt resource-server enforcement, keep access-token lifetimes short and require a server-side session check for sensitive health data.
Failure signals, tests, and observability
I keep transport failures separate from protocol failures in the eval harness. A 502 from a proxy, a malformed callback, invalid_grant, and a user cancellation should produce different counters and different next actions. One generic oauth_retry_total metric hides the decision that matters.
The minimum test matrix includes duplicate callbacks, swapped state values, a callback that arrives after the 300-second expiry, a token timeout after provider acceptance, and deletion repeated with the same request key. Add property tests for the invariant: after deletion is marked complete, no new session can be created for that account.
Trace an attempt ID across the authorization preparation, callback validation, exchange, and deletion worker. Redact codes, tokens, email addresses, and health identifiers. Alert on a spike in exchange_unknown, but do not auto-replay those attempts. A human-visible recovery page can explain that authorization expired and provide a fresh sign-in link without revealing provider details.
Choosing friction deliberately
| Situation | Retry or restart | Reason |
|---|---|---|
| State record write timed out before redirect | Retry with same key | No callback credential exists yet |
| Browser refreshes before callback | Reuse unexpired attempt once | State and PKCE still bind the request |
| Callback has invalid state or nonce | Restart | The response is not trustworthy |
| Token exchange timed out | Restart after marking unknown | Code may already be consumed |
| Deletion request repeats | Return existing status | The command is idempotent |
This policy is not suitable when your product promises silent, zero-click recovery for every provider error. Choose a provider-specific recovery flow or a broker that can expose transaction status when that UX requirement dominates. Stick with the strict restart rule when health data, delegated access, or shared devices raise the cost of a session mistake.
I am not sure every team needs the same expiry window; provider code lifetimes and support goals should decide it. What should remain constant is the evidence: measure replay rejection, deletion completion, and user drop-off separately, then adjust the friction with those numbers in view.
Top comments (0)