Handling PSD2 Consent Lifecycle States in a NestJS Backend
A customer connects their bank account to a budgeting app. Three months later they forget the connection even exists. Under PSD2, that connection cannot just quietly keep working forever, it has to be re confirmed period
A customer connects their bank account to a budgeting app. Three months later they forget the connection even exists. Under PSD2, that connection cannot just quietly keep working forever, it has to be re confirmed periodically, it can be partially revoked for one account while staying active for another, and if the customer never comes back to reauthorize it, access has to stop automatically, not because someone remembered to check, but because the system itself enforces it.
This is the part of open banking that trips up a lot of backends. Building the endpoints that move money or fetch transactions is the easy part. Correctly modeling consent as something that moves through real, regulated states over time is where a lot of the actual engineering difficulty lives, and it is also one of the most specific, checkable requirements a regulator or an auditor will actually test.
Why a simple yes or no is not enough
The instinct is to treat consent as a single boolean, granted or not granted. PSD2 does not work that way. Consent has a defined shape, it starts, it gets confirmed, it becomes active, it eventually needs reauthorization, it can expire, and it can be revoked, sometimes for specific accounts rather than the whole connection at once.
Treating this as a boolean means you lose the ability to answer basic questions later, like when exactly did this consent become active, or was this specific account still authorized at the moment this data was accessed. Those are exactly the questions that come up during a compliance review.
Modeling consent as a real state machine
The first real step is representing consent as an explicit set of states, with clear rules about which transitions are actually allowed.
export enum ConsentStatus {
INITIATED = 'initiated',
AUTHORIZED = 'authorized',
ACTIVE = 'active',
REAUTH_PENDING = 'reauth_pending',
EXPIRED = 'expired',
REVOKED = 'revoked',
}
const ALLOWED_TRANSITIONS: Record<ConsentStatus, ConsentStatus[]> = {
[ConsentStatus.INITIATED]: [ConsentStatus.AUTHORIZED, ConsentStatus.REVOKED],
[ConsentStatus.AUTHORIZED]: [ConsentStatus.ACTIVE, ConsentStatus.REVOKED],
[ConsentStatus.ACTIVE]: [ConsentStatus.REAUTH_PENDING, ConsentStatus.REVOKED],
[ConsentStatus.REAUTH_PENDING]: [ConsentStatus.ACTIVE, ConsentStatus.EXPIRED, ConsentStatus.REVOKED],
[ConsentStatus.EXPIRED]: [],
[ConsentStatus.REVOKED]: [],
};
Defining allowed transitions explicitly, rather than just setting a new status wherever convenient in the code, prevents a whole category of bugs where consent accidentally moves backward, or jumps into a state it was never actually supposed to reach.
Enforcing transitions through a dedicated service
A service that owns consent transitions, and refuses anything not explicitly allowed, keeps this logic in one place instead of scattered across every controller that happens to touch consent.
@Injectable()
export class ConsentService {
constructor(
@InjectRepository(Consent)
private readonly consentRepo: Repository<Consent>,
) {}
async transition(consentId: string, nextStatus: ConsentStatus): Promise<Consent> {
const consent = await this.consentRepo.findOneOrFail({ where: { id: consentId } });
const allowed = ALLOWED_TRANSITIONS[consent.status];
if (!allowed.includes(nextStatus)) {
throw new BadRequestException(
`Cannot move consent from ${consent.status} to ${nextStatus}`,
);
}
consent.status = nextStatus;
consent.updatedAt = new Date();
await this.consentRepo.save(consent);
return consent;
}
}
Any attempt to move consent into an invalid state fails loudly and immediately, rather than silently corrupting the record, which matters a great deal when this record might be examined later as evidence of compliance.
Account level granularity, not just one flag per connection
A real connection often covers several accounts, and PSD2 allows a customer to revoke access to just one of them without ending the entire relationship. This means consent cannot only live at the connection level, it needs to track which specific accounts remain authorized.
@Entity()
export class Consent {
@Column({ type: 'enum', enum: ConsentStatus })
status: ConsentStatus;
@Column()
customerId: string;
@Column()
thirdPartyId: string;
@Column('simple-array')
authorizedAccountIds: string[];
@Column({ nullable: true })
expiresAt: Date;
}
@Injectable()
export class AccountConsentService {
constructor(private readonly consentRepo: Repository<Consent>) {}
async revokeAccount(consentId: string, accountId: string): Promise<void> {
const consent = await this.consentRepo.findOneOrFail({ where: { id: consentId } });
consent.authorizedAccountIds = consent.authorizedAccountIds.filter(
(id) => id !== accountId,
);
if (consent.authorizedAccountIds.length === 0) {
consent.status = ConsentStatus.REVOKED;
}
await this.consentRepo.save(consent);
}
}
The connection as a whole only moves to fully revoked once every individual account has been removed, which matches how PSD2 actually expects partial revocation to behave.
Automatically expiring consent that nobody reauthorized
PSD2 generally expects consent to require periodic reauthorization, commonly around ninety days. This should not depend on someone remembering to check, it needs to run automatically.
@Injectable()
export class ConsentExpiryScheduler {
constructor(private readonly consentService: ConsentService) {}
@Cron('0 2 * * *')
async expireStaleConsents(): Promise<void> {
const staleConsents = await this.consentService.findConsentsPastReauthWindow();
for (const consent of staleConsents) {
await this.consentService.transition(consent.id, ConsentStatus.EXPIRED);
}
}
}
Running this daily means a customer who never comes back to reauthorize loses access automatically, on schedule, without depending on any manual step, which is exactly the guarantee a regulator expects this kind of system to provide.
The bigger picture
None of this is really about payments or transaction data. It is about being able to prove, at any point in time, exactly what a customer agreed to, for which accounts, and whether that agreement is still valid right now. NestJS does not know anything about PSD2 specifically, but its structure, a dedicated service owning transitions, a scheduler enforcing expiry automatically, entities that track granular state, gives you a natural place to build this correctly instead of leaving consent logic scattered and inconsistent.
Getting this right early matters more than it might seem, since consent handling is exactly the kind of thing regulators and auditors check closely, and it is far more expensive to retrofit a proper state machine into a system that started with a simple boolean than to build it correctly from the start.
If you are building open banking infrastructure and want consent handled with this level of rigor from day one, this is exactly the kind of work I focus on.
I am Peace Melodi, a backend software engineer. If you want your business to scale big, comfortably handling millions of users without breaking, with strong scalability and security in place, feel free to reach out.
LinkedIn: https://www.linkedin.com/in/melodi-peace-406494368
GitHub: https://github.com/PeaceMelodi
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.