How to test 90-day reauthentication, consent expiry, and token refresh in open banking
Test open banking reauthentication by driving each consent through an injected clock: EU strong customer authentication through an account information provider at day 180 and 181, UK consent reconfirmation before day 90, token refresh, revocation, and expiry. The check that catches silent data loss is two parallel refreshes of one rotating OAuth 2.0 refresh token.
Where consent and token defects stop the data feed
One defect is a scheduler that still sends customers back to their bank every 90 days in the EU, or counts 180 days from the day the consent was created instead of from the last strong customer authentication. Customers drop out at a prompt the rule no longer requires, and the product keeps showing stale account data as current. The second defect is quieter: two workers refresh the same OAuth token at once, the bank revokes the whole grant, and every background pull fails until the customer returns to the app.
For an aggregator paid per connection and per request, each dead grant is a connection that stops earning. For a lender or accounting product reading the feed, a credit or reconciliation decision runs on balances that stopped updating days earlier. The open banking segment page maps these failures to each revenue model.
The EU rule on 22 September 2026
Commission Delegated Regulation (EU) 2022/2360 of 3 August 2022 (OJ L 312, 5 December 2022, read on 2026-09-22) amended the regulatory technical standards (RTS) in Delegated Regulation (EU) 2018/389 and applies from 25 July 2023:
- Article 10, direct access. A bank "shall be allowed not to apply" strong customer authentication (SCA) when the customer accesses only the balance or the payment transactions executed in the last 90 days. It must apply SCA on first access, or when more than 180 days have elapsed since the last access with SCA.
- Article 10a(1) and (2), access through an account information provider. For the same two data items, the bank "shall not apply" SCA, except on first access through that provider, or when more than 180 days have elapsed since the last access through that provider with SCA.
- Article 10a(3). The bank may still apply SCA when it has objectively justified and duly evidenced reasons relating to unauthorised or fraudulent access, and documents them to its national competent authority on request.
- Article 30(4a). Interface changes made to comply with Article 10a reach providers at least 2 months before implementation.
On 22 September 2026, the Legislative Observatory shows both PSD3, file 2023/0209(COD), and the Payment Services Regulation, file 2023/0210(COD), at "Awaiting Council's 1st reading position", with ECON committee approval of the agreed texts on 5 May 2026 and an indicative plenary date of 14 December 2026. Neither act is in the Official Journal, so Article 10a remains the rule; the PSD3 and PSR page tracks the agreed articles, and the PSD2 SCA page covers the other exemptions.
The UK rule
The FCA removed 90-day reauthentication with the bank through PS21/19, published 29 November 2021 (read on 2026-09-22). Instrument FCA 2021/45 brought UK SCA-RTS Articles 10A and 36(6) into force on 26 March 2022:
- Article 10A. A bank "shall be allowed not to apply" SCA for the balance and last-90-day transactions requested through an account information provider, provided SCA was applied on at least one previous access by that provider. The exemption is permissive; PS21/19 states that the bank may require SCA at any time and strongly encourages it to rely on the exemption.
- Article 36(6). The provider may access data without the customer actively requesting it only if the customer confirmed with the provider, within the previous 90 days, that they continue to consent. PS21/19 states that where the customer does not reconfirm after 90 days, the provider must stop access, and that access may restart after a later reconfirmation.
The transition described on the FCA SCA page (read on 2026-09-22) ended on 30 September 2022.
In the US, the authorization duration under 12 CFR 1033.421 and the rule's current status are on the CFPB Section 1033 page.
Procedure
1. Put the reauthentication rules behind an injected clock
The two rules have different anchors. The EU count starts at the last access through the provider with SCA. The UK count starts at the customer's last confirmation to the provider, which is the original consent or a later reconfirmation. Neither count starts at the last data pull, and the EU count does not start at consent creation.
from datetime import datetime, timedelta, timezone
EU_SCA_RENEWAL = timedelta(days=180) # RTS Article 10a(2)(b), Delegated Regulation (EU) 2022/2360
UK_RECONFIRM = timedelta(days=90) # UK SCA-RTS Article 36(6)
def eu_sca_required(last_sca_via_provider: datetime | None, now: datetime) -> bool:
"""SCA on first access through the provider, or when more than 180 days have elapsed."""
if last_sca_via_provider is None:
return True
return now - last_sca_via_provider > EU_SCA_RENEWAL
def uk_unattended_access_allowed(last_confirmed: datetime | None, now: datetime) -> bool:
"""Our reading of 'within the previous 90 days': exactly 90 days is outside."""
if last_confirmed is None:
return False
return now - last_confirmed < UK_RECONFIRM
T0 = datetime(2026, 3, 2, 9, 0, tzinfo=timezone.utc)
def test_eu_first_access_needs_sca():
assert eu_sca_required(None, T0)
def test_eu_exactly_180_days_is_exempt():
assert not eu_sca_required(T0, T0 + timedelta(days=180))
def test_eu_one_second_past_180_days_needs_sca():
assert eu_sca_required(T0, T0 + timedelta(days=180, seconds=1))
def test_uk_day_89_allowed():
assert uk_unattended_access_allowed(T0, T0 + timedelta(days=89, hours=23))
def test_uk_day_90_blocked():
assert not uk_unattended_access_allowed(T0, T0 + timedelta(days=90))Run the product's own scheduler against the same clock values. A reauthentication prompt shown to an EU customer on day 90 is a defect; a UK background pull more than 90 days after the last reconfirmation breaks Article 36(6).
2. Refresh the access token as RFC 6749 section 6 defines it
RFC 6749 section 6 defines the refresh request. When the client authenticates with mutual TLS, RFC 8705 requires the client_id parameter in the request.
POST /as/token.oauth2 HTTP/1.1
Host: auth.api.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=rt_test_7Qx2Lw&client_id=tpp_test_client{
"access_token": "at_test_n4Vb1k",
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "rt_test_Q9pZr3"
}Section 6 lets the server issue a new refresh token; the client then must discard the old one, and the server may revoke it. A rejected refresh returns HTTP 400 with invalid_grant, which section 5.2 defines for a refresh token that is "invalid, expired, revoked" or issued to another client:
HTTP/1.1 400 Bad Request
Content-Type: application/json
Cache-Control: no-store
{"error":"invalid_grant","error_description":"refresh token is not active"}The test asserts that the product stores the new refresh token before it uses the new access token, and that a lost response does not leave the stored token pointing at a revoked value.
3. Race two refreshes on one grant
import concurrent.futures
import requests
TOKEN_URL = "https://auth.api.example.com/as/token.oauth2"
CLIENT_CERT = ("tpp_test.crt", "tpp_test.key")
CLIENT_ID = "tpp_test_client"
def refresh(token: str) -> tuple[int | None, dict]:
form = {"grant_type": "refresh_token", "refresh_token": token, "client_id": CLIENT_ID}
try:
resp = requests.post(TOKEN_URL, data=form, cert=CLIENT_CERT, timeout=10)
except requests.RequestException as exc:
return None, {"error": "transport", "detail": type(exc).__name__}
try:
return resp.status_code, resp.json()
except ValueError:
return resp.status_code, {"error": "non_json", "detail": resp.text[:200]}
def race(refresh_token: str) -> str:
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(refresh, [refresh_token, refresh_token]))
winners = [body for status, body in results if status == 200]
if not winners:
return f"no refresh succeeded: {results}"
follow_up = winners[0].get("refresh_token", refresh_token)
status, body = refresh(follow_up)
if status == 200:
return "grant survived the race"
return f"grant revoked after the race: {status} {body.get('error')}"
if __name__ == "__main__":
print(race("rt_test_7Qx2Lw"))Run it once against each bank sandbox and record the result per bank. "Grant revoked after the race" means the product must serialise refreshes per consent.
4. Read expired and revoked consents the way the bank reports them
The UK Read/Write Data API profile v4.0.1 (read on 2026-09-22) returns 401 for an expired access token. When the bank expires the token because the consent end date passed, the consent moves to EXPD with reason TKXP; when the bank requires SCA again, it moves to CANC with TKSP and U028. Normal access token expiry must not change the consent status. The provider reads the status with a client credentials token, subject to the bank's fair usage policy:
GET /open-banking/v4.0/aisp/account-access-consents/urn-testbank-intent-0001 HTTP/1.1
Host: api.example.com
Authorization: Bearer cc_test_token_not_real
x-fapi-interaction-id: 7b0e6c1e-3f7d-4d52-9b1a-2f0c8e5d6a10
Accept: application/json{
"Data": {
"ConsentId": "urn-testbank-intent-0001",
"Status": "EXPD",
"StatusReason": [{ "StatusReasonCode": "TKXP" }],
"StatusUpdateDateTime": "2027-03-01T00:00:05+00:00",
"CreationDateTime": "2026-09-01T10:00:00+00:00",
"Permissions": ["ReadAccountsDetail", "ReadBalances", "ReadTransactionsCredits", "ReadTransactionsDebits", "ReadTransactionsDetail"],
"ExpirationDateTime": "2027-03-01T00:00:00+00:00"
},
"Risk": {},
"Links": { "Self": "https://api.example.com/open-banking/v4.0/aisp/account-access-consents/urn-testbank-intent-0001" },
"Meta": { "TotalPages": 1 }
}The Account and Transaction API profile allows re-authentication of the same consent while its status is AUTH or CANC and its ExpirationDateTime has not passed. A product that creates a new consent for every reauthorisation leaves duplicates at the bank.
Berlin Group NextGenPSD2 (OpenAPI file v1.3.16, dated 27 November 2025, read on 2026-09-22) exposes the status separately. consentStatus takes received, rejected, valid, revokedByPsu, expired, terminatedByTpp, or partiallyAuthorised, and its HTTP 401 message codes for account information include CONSENT_EXPIRED and CONSENT_INVALID:
GET /v1/consents/test-consent-0001/status HTTP/1.1
Host: api.example.com
X-Request-ID: 99391c7e-ad88-49ec-a2ad-99ddcb1f7721
Authorization: Bearer at_test_not_real{ "consentStatus": "revokedByPsu" }{ "tppMessages": [{ "category": "ERROR", "code": "CONSENT_EXPIRED" }] }Map each value to one product state. On revokedByPsu, the customer withdrew access at the bank, so the product stops pulls without retrying.
5. Check every clock the flow depends on
expires_in is relative: RFC 6749 section 5.1 counts it from the time the server generated the response. The UK http://openbanking.org.uk/refresh_token_expires_at claim, the Berlin Group validUntil date, and the RTS day counts are absolute and meet the provider's own clock.
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
def access_token_deadline(expires_in: int, request_sent_at: datetime) -> datetime:
"""Anchor on the send time, which precedes the server's response time, minus a 30-second margin."""
return request_sent_at + timedelta(seconds=expires_in - 30)
def berlin_group_consent_end(valid_until: str, bank_zone: str) -> datetime:
"""validUntil is an inclusive date in the bank's local time; access ends at the next local midnight."""
day = date.fromisoformat(valid_until)
return datetime.combine(day + timedelta(days=1), time.min, tzinfo=ZoneInfo(bank_zone))
sent = datetime(2026, 9, 22, 8, 0, 0, tzinfo=timezone.utc)
assert access_token_deadline(300, sent) == datetime(2026, 9, 22, 8, 4, 30, tzinfo=timezone.utc)
assert berlin_group_consent_end("2027-03-31", "Europe/Berlin").astimezone(timezone.utc) == datetime(
2027, 3, 31, 22, 0, tzinfo=timezone.utc
)The anchors and races vendor documentation leaves to you
Rotation turns a race into a lost grant. RFC 9700 section 4.14.2 (January 2025) describes reuse detection: when an invalidated refresh token is presented, the server cannot tell attacker from client, so it revokes the active refresh token too, "at the cost of forcing the legitimate client to obtain a fresh authorization grant". A bank may rotate tokens for confidential clients too, under RFC 6749 section 6. Two workers sharing a consent, or a retry after a timed-out refresh, then present the old token and kill the grant. A fresh grant needs the customer back in a redirect to the bank. Serialise refreshes with a lock keyed by consent ID, and treat invalid_grant straight after a parallel refresh as a race signal before you mark the consent revoked. The UK profile adds one rule: after re-authentication, the provider must not use access or refresh tokens issued earlier for that consent.
The 180-day anchor moves. Any access through the provider where SCA was applied restarts the EU count, including SCA the bank applied under Article 10a(3). Record the timestamp of every SCA and derive the next due date from the latest one. The exemption also covers only the balance and transactions executed in the last 90 days; a request for older history falls outside it on any day, which the SCA exemptions testing guide treats as a separate case.
validUntil ends at midnight in the bank's time zone. The Berlin Group OpenAPI defines validUntil as "the local ASPSP date", inclusive, and allows the bank to adjust future dates. A provider in UTC that reads "2027-03-31" as ending at 23:59:59 UTC keeps calling a Berlin bank for two hours after its consent has expired and collects CONSENT_EXPIRED errors. A provider that reads it as 00:00 UTC stops almost a day early. Read the date back after creation, because the bank may have shortened it.
Test matrix
| Case | Clock or input | Expected result |
|---|---|---|
| EU first access through provider | No prior SCA | SCA required |
| EU day 180 | Exactly 180 days after last SCA | Background pull succeeds |
| EU past day 180 | 180 days and 1 second after last SCA | Customer asked for SCA |
| EU older history | Transactions older than 90 days | Treated as outside the exemption |
| UK day 89 | 89 days 23 hours after reconfirmation | Background pull succeeds |
| UK day 90 | 90 days after reconfirmation | Pulls stop, reconfirmation requested |
| UK bank asks for SCA | Consent CANC with TKSP and U028 | Same consent re-authenticated |
| Access token expiry | Token past expires_in | Refresh, consent status unchanged |
| Parallel refresh | Two refreshes, one token | One grant survives |
| Revoked at bank | revokedByPsu | Pulls stop, no retries |
| Consent end date | validUntil 2027-03-31, Berlin bank | Last call before 2027-03-31T22:00:00Z |
The open banking API testing service runs this matrix as the consent and reauthorisation flow test suite, and the open banking standards page lists the UK and Berlin Group versions it runs against.
Related
Standards
Guides
Procedure on h2check.org
Questions
How long does open banking consent last?
How long do refresh tokens last?
Scope your testing
Send FintechAssuranceLabs your product, segment, money flows, and the standards that need evidence, and get a proposed test scope with its deliverables.
Scope your testing