DEV Community

John Frandsen
John Frandsen

Posted on

PSD2 Consent Expiry vs Token Refresh: Why Your Bank Connection Stops After 90 Days

You refresh your OAuth tokens on schedule. Your token endpoint returns 200s. Your job is green. And yet — at almost exactly 90 days, a chunk of your users' bank connections silently stop pulling transactions. The error rate ticks up at the same interval for everyone, regardless of when they last logged in. Token refresh is healthy, so what expired?

This is the single most common operational mystery in open-banking integrations, and the reason it's confusing is that PSD2 runs three independent timers, and most developers only reason about one of them. If you've only ever thought about OAuth access-token lifetime, you'll find that 90-day cliff inexplicable — because that one timer never reaches 90 days.

Let me untangle the three timers, the regulatory citations behind them, and how to tell them apart at runtime so you can recover correctly instead of blindly retrying.

The three timers at a glance

# Timer Set by Typical duration Recovery when it lapses
1 OAuth access token ASPSP token endpoint seconds–hours Silent refresh_token exchange, no user
2 Consent validUntil PSU (capped by bank, often 90d) 30–180 days Re-grant consent; silent if SCA still fresh
3 SCA re-authentication window PSD2 RTS, hard cap ≤180 days ≤180 days Redirect PSU to bank for strong authentication

The first one is OAuth as you know it. The other two are PSD2-specific and they are the timers that actually explain your 90-day problem. The trick is that token refresh cannot extend timer 2 or 3. A refresh gets you a new bearer token for a consent that itself is expiring, or for an SCA session that has gone stale.

Timer 1 — The access token (the one you already know)

When the PSU finishes the redirect flow, your ASPSP hands you a Bearer access_token plus, usually, a refresh_token. The access token is what you put in the Authorization: Bearer … header on /accounts, /balances, /transactions. Per the Berlin Group NextGenPSD2 Implementation Guidelines, this token's lifetime is implementation-defined and typically short — minutes to a couple of hours — because it's bound to a single consent and a single SCA session.

Refreshing it is the easy part:

def refresh_access_token(refresh_token: str) -> dict:
    r = httpx.post(
        f"{ASPSP_BASE}/as/token.oauth2",
        data={
            "grant_type": "refresh_token",
            "refresh_token": refresh_token,
        },
        auth=(CLIENT_ID, CLIENT_SECRET),   # or private_key_jwt
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        timeout=15,
    )
    r.raise_for_status()
    return r.json()  # {"access_token": ..., "expires_in": 600, "refresh_token": ...?}
Enter fullscreen mode Exit fullscreen mode

Two things to internalize:

  • refresh_token rotation is common. Many ASPSPs issue a new refresh token on every refresh and invalidate the old one. If you replay an old refresh token you'll get a 400. Persist the new one before you consider the refresh successful.
  • Refresh only ever gives you more access tokens for the same consent. It does not extend the consent's validUntil, and it does not reset the SCA clock. This is why a healthy refresh job doesn't prevent the 90-day cliff.

Timer 2 — Consent validUntil (this is your 90-day problem)

A consent in PSD2 is the PSU's authorization for you (the AISP) to read their account data. It is a first-class object on the bank's side, identified by consentId, with a lifecycle (receivedvalidexpired | revokedByPsu | terminatedByTpp). Every consent carries a validUntil date — the day after which the bank will refuse all access even if your token is technically valid.

This is the timer that defaults to 90 days at most ASPSPs, and it's the one that produces your synchronized error spike. Where does 90 days come from? Two places:

  1. PSU control. Under PSD2 Article 66, the PSU sets (or accepts a default for) the consent's validity period. Many banks simply default validUntil to 90 days from creation.
  2. National transposition and scheme defaults. UK Open Banking's original CMA9 requirements centred on 90-day re-authentication. German BaFin-influenced implementations, Polish, and several Nordic banks also default to 90. Some go as low as 30.

The PSD2 RTS itself does not mandate 90 days. The RTS ceiling is 180 days (see Timer 3). The 90-day value is a pervasive default, not a legal maximum — but because so many banks ship that default, 90 days is the number you'll see in production.

When validUntil passes, you get an explicit, distinguishable failure. NextGenPSD2 ASPSPs return it as a 403 with application/problem+json (RFC 7807) and a tppMessages array containing CONSENT_EXPIRED. The consent resource's status flips to expired. A refresh_token grant will still succeed against the token endpoint, and you will still get a brand-new access token — but every AIS call with that token returns 403. This is the exact trap: "my token refresh works, but every data call fails."

The recovery is re-grant the consent, which — critically — may or may not require the user to re-authenticate, depending on Timer 3.

Timer 3 — SCA re-authentication (the hard wall PSD2 imposes)

Strong Customer Authentication (SCA) is the act of the PSU proving their identity to their bank (two-factor: something they know + have + are). PSD2's Regulatory Technical Standards — Commission Delegated Regulation (EU) 2018/389, Article 10 — set the maximum interval between two SCAs for account-information access at 180 days. In plain terms: an ASPSP is permitted to not re-challenge the PSU with SCA for up to 180 days after the last SCA, and after 180 days a fresh SCA is mandatory.

This is the one timer that is legally fixed, bank-independent, and uncrossable by any background process. No amount of token refreshing or consent re-granting lets you cross 180 days without the PSU doing strong authentication again.

Here's the subtle part that ties it to Timer 2: a consent can be technically valid (not past validUntil, not revoked) while its SCA is stale. So you can hit two different failures against the same consent:

  • Consent past validUntilCONSENT_EXPIRED (re-grant; silent if SCA fresh)
  • SCA older than 180 days → the bank forces an SCA on the next consent grant → you must redirect the PSU

The interaction is what trips people up. Many banks deliberately set validUntil = 90 days so that consent renewal always happens well inside the 180-day SCA window, which lets renewal be a silent one-tap re-approval rather than a full redirect. That's the UX-friendly interpretation of "90 days." But if you configured a longer validUntil (e.g., 170 days to minimise user friction), the renewal will collide with the SCA window and force a redirect + 2FA on the PSU. You traded frequency for friction and lost on both.

A reasonable rule of thumb: treat 90 days as the renewal cadence and 180 days as the cliff you must never reach. Renew at day ~80, and you'll always be inside the SCA window, which lets renewal be silent or one-tap.

Reading the signals: which timer just fired?

The cheapest debugging win is mapping the HTTP response to the responsible timer, because each has a different recovery path. ASPSPs conforming to NextGenPSD2 return errors as RFC 7807 problem documents:

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{
  "type": "https://example.com/errors/forbidden",
  "title": "Consent expired",
  "status": 403,
  "tppMessages": [
    { "category": "ERROR", "code": "CONSENT_EXPIRED",
      "text": "The consent used is expired." }
  ]
}
Enter fullscreen mode Exit fullscreen mode
HTTP status tppMessages.code Timer fired Recovery
401 TOKEN_INVALID / TOKEN_EXPIRED 1 — access token Refresh silently
403 CONSENT_EXPIRED 2 — validUntil Re-grant consent (silent if SCA fresh)
401/403 CONSENT_INVALID / CONSENT_STATUS_INVALID 2 — revoked/terminated Re-grant; expect SCA
403 CONSENT_UNKNOWN bad consentId Re-grant
401 (on consent grant) SCA required 3 — SCA >180d Redirect PSU to bank

The status code alone is not enough — a 401 from a token-endpoint failure means something different from a 401 on an AIS endpoint. Always parse tppMessages.code; that's the contract NextGenPSD2 gives you to disambiguate.

A recovery orchestrator

Here's the pattern I use to keep this sane: one wrapper that classifies the failure and routes to the correct recovery, instead of scattering retry logic across call sites.

import httpx, time
from dataclasses import dataclass
from enum import Enum

class Recovery(str, Enum):
    RETRY = "retry"                 # transient; just call again
    REFRESH_TOKEN = "refresh"       # timer 1
    REGRANT_CONSENT = "regrant"     # timer 2
    USER_ACTION_SCA = "user_sca"    # timer 3

@dataclass
class ConsentState:
    consent_id: str
    valid_until: float              # epoch
    last_sca_at: float              # epoch
    access_token: str
    access_token_exp: float         # epoch
    refresh_token: str

def classify(resp: httpx.Response, state: ConsentState) -> Recovery:
    codes = {m["code"] for m in resp.json().get("tppMessages", [])}
    if resp.status_code == 401 and (codes & {"TOKEN_INVALID", "TOKEN_EXPIRED"}):
        return Recovery.REFRESH_TOKEN
    if codes & {"CONSENT_EXPIRED"}:
        # consent's validUntil has passed; re-grant.
        # Whether the PSU must do SCA depends on timer 3:
        if time.time() - state.last_sca_at > 170 * 86400:
            return Recovery.USER_ACTION_SCA
        return Recovery.REGRANT_CONSENT
    if codes & {"CONSENT_INVALID", "CONSENT_UNKNOWN", "CONSENT_STATUS_INVALID"}:
        return Recovery.USER_ACTION_SCA     # revoked or corrupted; full re-grant safest
    if resp.status_code >= 500:
        return Recovery.RETRY
    raise RuntimeError(f"unhandled: {resp.status_code} {codes}")
Enter fullscreen mode Exit fullscreen mode

The key design decision: CONSENT_EXPIRED does not unconditionally mean "redirect the user." It only means the user must re-authenticate if SCA is also stale (timer 3). If SCA is fresh, re-granting the consent can be a server-to-server call that returns a new consentId without ever bouncing the PSU to their bank. That's the difference between a 0.1% drop-off and a 20% drop-off on renewal day.

The companion function does the refresh side, so the call site stays clean:

def call_ais(state: ConsentState, path: str) -> dict:
    for attempt in range(4):
        if time.time() > state.access_token_exp - 30:
            _refresh(state)                        # timer 1, proactive
        r = httpx.get(f"{ASPSP_BASE}{path}",
                      headers={"Authorization": f"Bearer {state.access_token}",
                               "Consent-ID": state.consent_id},
                      timeout=20)
        if r.status_code == 200:
            return r.json()
        match classify(r, state):
            case Recovery.REFRESH_TOKEN:    _refresh(state); continue
            case Recovery.RETRY:            time.sleep(2 ** attempt); continue
            case Recovery.REGRANT_CONSENT:  _regrant(state); continue
            case Recovery.USER_ACTION_SCA:  raise NeedsUserAction(state.consent_id)
Enter fullscreen mode Exit fullscreen mode

NeedsUserAction is the signal to your higher layer to send a notification ("tap here to reconnect your bank") instead of looping forever in the background.

Don't wait for the user to hit the error

The mistake that produces the synchronized 90-day cliff is reactive renewal. If you only re-grant when a read call fails, then every PSU who consented in the same onboarding wave hits CONSENT_EXPIRED within the same week, and they all get a reconnect prompt at once — usually in a batch that overwhelms whatever SCA capacity the bank has, and always at a worse UX moment than the original onboarding.

The fix is a scheduled sweeper that renews each consent ~10 days before whichever timer is closer to lapsed:

def sweep(due_soon_days: int = 10):
    for state in consent_store.iter():
        d_to_consent = state.valid_until - time.time()
        d_to_sca     = state.last_sca_at + 180*86400 - time.time()
        d = min(d_to_consent, d_to_sca)
        if d < due_soon_days * 86400:
            if d == d_to_sca:
                notify_user(state, "reconnect")     # timer 3: needs SCA, user must act
            else:
                _regrant(state)                     # timer 2: silent renewal
Enter fullscreen mode Exit fullscreen mode

Now renewals spread out across the calendar because they're driven by each PSU's individual consent timestamp, and the SCA-bound ones get queued for the user instead of crashing a read call.

Why banks vary so much

A short, non-exhaustive map of the defaults you'll actually hit:

Region / scheme Typical validUntil default Notes
UK (CMA9, OBIE) 90 days Historical re-auth cadence; FCA aligned SCA ceiling to RTS 180d
Germany (Berlin Group) 90 days Common; some Sparkassen allow up to 180
Nordics 90–180 days Decoupled flows common
Poland (Polish API) up to 180 days Often closer to the RTS ceiling
France (STET) 90–180 days Bank-dependent

Always read the specific ASPSP's documentation for both validUntil policy and whether refresh-token rotation is enabled. They differ per bank even within the same scheme, and they're the two settings that break naive integrations.

The mistakes I see most often

  1. Treating 401 and 403 as "token expired." A CONSENT_EXPIRED 403 is not a token problem; refreshing won't help and will waste your rate budget.
  2. Re-granting on every 401. Some teams panic and kick the user back through SCA for any failure. You burn the 180-day SCA goodwill and annoy users. Classify first.
  3. Renewing reactively. All consents from a launch cohort lapse together. Sweep proactively.
  4. Assuming refresh tokens are long-lived. Rotation invalidates them. Persist the new token in the same transaction as the refresh.
  5. Setting validUntil too long to "reduce friction." Past ~150 days you collide with SCA and force a redirect anyway — at a worse time. 90-day renewal inside a fresh SCA window is the local optimum.

Takeaways

  • Three timers, one OAuth mental model will not save you. Access token (short, silent), consent validUntil (≈90d, re-grant), SCA (≤180d, RTS Art. 10, must involve the PSU).
  • Token refresh extends timer 1 only. It never extends timers 2 or 3, which is the whole reason the 90-day cliff exists despite healthy refresh jobs.
  • Classify failures by tppMessages.code, not by status code alone. CONSENT_EXPIRED, TOKEN_EXPIRED, and a forced SCA each demand a different recovery path.
  • Sweep proactively. Renew ~10 days before the nearest timer lapses, route SCA-bound renewals to the user, and let the rest happen silently.

Get the classification and the sweeper right and the 90-day cliff stops being a mystery outage and becomes a quiet, spread-out background job — which is exactly what open banking should feel like when it's working.


Disclosure: I maintain open-banking.io, a self-hosted PSD2/open banking platform.

Top comments (0)