DEV Community

Cover image for The Quiet Risk in How Most Banks Store Customer Documents, and How NestJS Handles It Properly
Peace Melodi
Peace Melodi

Posted on

The Quiet Risk in How Most Banks Store Customer Documents, and How NestJS Handles It Properly

A compliance officer at a bank was preparing for an upcoming audit when she asked a question nobody on the engineering team had a clean answer to. Every customer who opened an account had uploaded a government ID and a proof of address. Where exactly were those files sitting, who could actually open them, and could the bank prove that. The honest answer was uncomfortable. The files were sitting in a storage bucket, reachable by a plain public link, generated once at upload time and never expiring. Anyone who ever got hold of one of those links, through a shared screenshot, a browser history, a support ticket, could open a customer's ID months or years later. Nothing had leaked yet. But nobody could say with any confidence that it never would.

This is not a rare mistake. It is what happens by default when file upload is treated as a simple feature instead of something that needs the same discipline as handling money itself.

Why this risk hides so well

Uploading a document and storing a link to it works, in the sense that the feature functions and nobody notices a problem in testing. The risk is invisible until the exact moment it is not, a leaked link, a support agent who should never have seen a customer's ID, a storage bucket accidentally left open to the public. By the time it is noticed, the damage is already done, and for a bank, a document like a government ID or a bank statement is not something you get to quietly walk back.

Never storing documents with a permanent public link

The first fix is making sure a document is never reachable through a link that lasts forever. Instead of a public URL, the file should sit in private storage, and access should only be granted through a short lived, signed link generated on demand.

import { Injectable } from '@nestjs/common';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

@Injectable()
export class DocumentAccessService {
  private readonly s3Client = new S3Client({ region: 'us-east-1' });

  async generateAccessUrl(bucket: string, key: string): Promise<string> {
    const command = new GetObjectCommand({ Bucket: bucket, Key: key });

    return getSignedUrl(this.s3Client, command, { expiresIn: 300 });
  }
}
Enter fullscreen mode Exit fullscreen mode

A link like this stops working after a few minutes. If it ever leaks, whoever finds it has already missed the window to use it.

Enforcing who is allowed to even ask for a document

Generating a short lived link is only half the fix. The endpoint that generates it has to strictly confirm the requester is actually allowed to see that specific document, not just that they are logged in.

import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';

@Injectable()
export class DocumentOwnershipGuard implements CanActivate {
  constructor(private readonly documentsService: DocumentsService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const requestingUserId = request.user.id;
    const documentId = request.params.documentId;

    const document = await this.documentsService.findById(documentId);

    if (!document || document.ownerId !== requestingUserId) {
      throw new ForbiddenException('You do not have access to this document');
    }

    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

This guard sits in front of the route, so nobody, including a support agent using their own account, can pull up a document that does not belong to the customer they are actually assisting, unless that access is explicitly modeled and permitted.

Recording every time a document is accessed

The compliance officer's real question was whether the bank could prove who accessed a document and when. That means every single access needs to be logged, not just allowed or denied.

import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm';

@Entity('document_access_logs')
export class DocumentAccessLog {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  documentId: string;

  @Column()
  accessedByUserId: string;

  @Column()
  reason: string;

  @CreateDateColumn()
  accessedAt: Date;
}
Enter fullscreen mode Exit fullscreen mode

With this in place, an audit question like who looked at this customer's ID and why becomes something the bank can answer with a query, not a guess.

The bigger picture

NestJS does not know anything about compliance requirements on its own. What it gives you is a place to put each layer of this properly, a service that only ever issues short lived signed access instead of permanent links, a guard that checks real ownership before anything is generated, and a log that records every access without exception. None of these pieces are complicated individually. What matters is that the structure makes it hard to skip any of them under deadline pressure, since that is usually how a customer's sensitive documents end up sitting behind a link that never expires in the first place.

If your team is storing anything sensitive, documents, IDs, statements, and you are not fully sure who could actually access them today, I would be glad to talk through how to tighten that up.

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 (0)