How I Detect Phone Numbers and Payment Details in Chat Messages

How I Detect Phone Numbers and Payment Details in Chat Messages In a meetup app, chat is where things go wrong. Someone sends a phone number in the first message. Someone else asks for a UPI payment. A stranger shares a home address before the two have even met.

I didn’t want the app to read private messages or block them. I wanted something lighter: spot risky details, hide them by default, and let the receiver decide whether to look.

This post shows how I built that with Node.js and MongoDB. It covers the scanner, the message schema, and the reveal route.

The idea in one line

Scan every message on the server before saving it. If it looks sensitive, store a flag. Then blur it for the receiver until they tap to reveal.

Step 1: Add flags to the message schema

The scan result has to live somewhere, so I put it on the message itself.

export type SensitiveKind = 'phone' | 'upi' | 'address' | 'pii';

export interface IMessage extends Document {
  chatId: mongoose.Types.ObjectId;
  senderId: mongoose.Types.ObjectId;
  content: string;
  type: 'text' | 'image' | 'location';
  containsSensitive: boolean;
  sensitiveKinds: SensitiveKind[];
  revealedBy: mongoose.Types.ObjectId[];
  status: 'sent' | 'delivered' | 'read';
  createdAt: Date;
  updatedAt: Date;
}

Three fields do the work:

  • containsSensitive is a simple yes or no. It defaults to false.
  • sensitiveKinds says what was found: phone, UPI, address or ID number. The app uses it to show a label like “sensitive · phone”.
  • revealedBy is a list of user IDs. Anyone in the list has tapped to reveal the message.

I also added a compound index on chatId and createdAt (newest first). Loading a chat’s recent messages is the most common query in the app, and this index keeps it quick.

Step 2: Write the scanner

The scanner is a single function. It takes the message text and returns a list of what it found.

const hits = scanSensitive(content);
const containsSensitive = hits.length > 0;
const sensitiveKinds = hits.map((h) => h.kind);

Inside, it’s a set of regular expressions, one per category.

ID numbers. This pattern catches 12-digit numbers in the Aadhaar format, with or without spaces and dashes:

/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/

Payment handles. This one catches UPI addresses like name@ybl, and also phrases like “gpay number 98…”:

/[a-z0-9._-]{2,}@(?:upi|paytm|gpay|phonepe|ybl|yapl|okhdfcbank|okaxis|apl|ibl)\b|\b(?:gpay|paytm|phonepe|bhim)\b[\s:]*(?:no|number|id)?[\s:#-]*[7-9]\d{9}/i

Phone numbers. I match Indian numbers (with +91 or a 10-digit number starting with 6 to 9) and common international formats.

Addresses. I look for words like flat, apartment, villa, house no, hostel and “come to my”, plus six-digit PIN codes.

Step 3: Never trust the client

The scan runs on the server, inside the route that creates a message. The client can’t switch it off or fake the result.

The same goes for the other fields. senderId, status, containsSensitive and revealedBy are all set by the server. If someone sends extra fields in the request body, they’re ignored. This is called mass-assignment protection, and it’s an easy one to forget.

const message = await Message.create({
  chatId: chat._id,
  senderId: user._id,
  content: content.trim(),
  type: type === 'image' || type === 'location' ? type : 'text',
  containsSensitive,
  sensitiveKinds,
  revealedBy: [],
  status: 'sent',
});

Step 4: Protect the places text leaks

Blurring the chat screen isn’t enough. Private text can leak in other places, so I handled two of them:

  • Push notifications. If a message is flagged, the notification says “Sent you a sensitive message” instead of showing the text.
  • The conversation list. The preview line under each chat name is replaced with “Sensitive Content” unless you sent the message or already revealed it.

Step 5: The reveal route

When the receiver taps a blurred message, the app calls this route:

router.put('/messages/:id/reveal', authenticate, async (req, res) => {
  const user = req.user!;

  if (!mongoose.Types.ObjectId.isValid(req.params.id)) {
    return res.status(404).json({ error: 'Message not found.' });
  }

  const msg = await Message.findById(req.params.id);
  if (!msg) return res.status(404).json({ error: 'Message not found.' });

  const chat = await Chat.findById(msg.chatId);
  if (!chat || !chat.participants.some((p) => p.toString() === user._id.toString())) {
    return res.status(403).json({ error: 'Access denied.' });
  }

  if (!msg.revealedBy.some((id) => id.toString() === user._id.toString())) {
    msg.revealedBy.push(user._id);
    await msg.save();
  }

  res.json(msg);
});

Three things matter here:

  1. Membership check. Before anything else, the route confirms the person is a participant in that chat. Without it, anyone with a message ID could reveal messages from chats they were never in. This kind of bug is called an IDOR.
  2. One reveal per person. The ID goes into revealedBy only if it isn’t there already, so repeated taps do nothing.
  3. Per-user state. Revealing a message for one person doesn’t reveal it for anyone else.

On the client, a message is blurred when containsSensitive is true and the user’s ID isn’t in revealedBy. After the tap, the plain text shows.

Where this falls short

I want to be honest about the limits, because a feature like this can sound stronger than it is.

  • The blur is a speed bump, not a lock. The text still reaches the receiver’s device, and the app only hides it. Someone technical could read it from the network response. The blur is there to make people pause, not to encrypt anything.
  • Regex makes mistakes. The word “flat” can show up in a message about a flat tyre. Any 12-digit number looks like an ID. And a determined person can write a number as “nine eight seven…” and get past it. The scanner catches the common cases, not every case.
  • Flagged does not mean blocked. The message still goes through. That was deliberate. I’d rather warn than censor.

What I’d improve next

  • Make the reveal atomic. Right now the route reads the message, changes it and saves it. If two taps arrive at the same moment, the data can get out of step. MongoDB’s $addToSet can do this in one update.
  • Keep a real record. Storing who revealed what, and when, would help moderation if someone reports a message.
  • Send less. The better fix for the blur limit is for the server to withhold the text until the reveal call, so it’s never sent early.

What to take away

  • Flag risky content on the server when the message is created, and store the result with the message.
  • Hide sensitive text everywhere it can appear: the chat, the notification and the list preview.
  • Check chat membership on every route that touches a message.
  • Be clear about what the feature does and doesn’t protect.

Related posts: How I Built Real-Time Chat in React with Socket.IO, and JWT Authorization with User, Moderator and Admin Roles in Node.js. How I Built Real-Time Chat in React with Socket.IO · knowabteverything.com

Implementing JWT Authorization with User Moderator and Admin Roles in Node.js · knowabteverything.com

Explore my Project here https://casual-meet.vercel.app/

Leave a Comment