DEV Community

Cover image for NestJS Remembers Consent Long After the Customer Forgets
Peace Melodi
Peace Melodi

Posted on

NestJS Remembers Consent Long After the Customer Forgets

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]: [],
};
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (2)

Collapse
 
johnfrandsen profile image
John Frandsen

The state machine framing is exactly right, and the ALLOWED_TRANSITIONS map is the pattern I'd recommend to anyone building this. One thing that doesn't get enough attention in consent lifecycle design is what happens at the boundary between consent state and data access.

The article covers the "is this consent valid right now" question well — the state machine answers that. But there's a second question that matters just as much in practice: "was this consent valid at the moment this specific data was fetched?" A consent can transition from ACTIVE → EXPIRED between a scheduled sync start and completion. If the sync job reads the consent status at T0, spends 30 seconds paginating through transactions, and the consent expires at T+15s, you've accessed data under a consent that was technically no longer valid for the second half of the fetch. The fix is recording the consent snapshot (status + valid_until timestamp) at fetch start and stamping each batch of transactions with that snapshot reference. Later, if an auditor asks "was this transaction access authorized," you can point to the exact consent state that authorized it, not just the current state.

The account-level granularity point is also where I've seen the most production bugs. Partial revocation (one account out of three) is easy to model on paper but hard to enforce when your sync job pulls a combined balance+transactions batch. The bank's API doesn't always let you filter by consent scope per request — sometimes you get all accounts in one response and have to apply the consent filter yourself. That means the consent filter has to live at your ingestion layer, not at the API call layer, because the API doesn't know which accounts your user has authorized for your app specifically.

The REAUTH_PENDING state is particularly well-placed. In practice, the 90-day SCA reauthorization window (under PSD2 RTS) is the single biggest source of silent connection failures — the consent doesn't expire, it just stops returning fresh data, and the user doesn't know until they notice their balances are stale. Treating REAUTH_PENDING as an explicit state that triggers a user notification, rather than waiting for EXPIRED, is the difference between a graceful degradation and a support ticket.

Great series — the consent lifecycle is the part of open banking that's invisible until it breaks, and you've laid out the failure modes clearly.

Collapse
 
peacemelodi profile image
Peace Melodi

Really good point on the snapshot problem. The state machine answers whether consent is valid right now, but not whether it was valid at the exact moment a specific batch of data was actually pulled, and a sync job spanning even thirty seconds can straddle that line. Recording the consent snapshot at fetch start and stamping each batch with it is the right fix, since it gives you something concrete to point to later instead of just the current state, which might have already moved on by the time anyone asks. The account level enforcement point is one I underweighted. Modeling partial revocation cleanly on the entity is one thing, but if the bank's API hands back all accounts in one combined response regardless of scope, the filtering has to happen at your own ingestion layer, not assumed away at the API boundary. That is exactly the kind of gap that looks fine in a diagram and breaks quietly in production. And REAUTH_PENDING triggering a notification rather than staying silent until EXPIRED is a good call. A connection that just quietly stops returning fresh data, with the customer none the wiser, is a worse outcome than an explicit nudge to reauthorize. Appreciate the depth here.