Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Tech Interview Preparation  Module 11 – Safety, ethics and your rights

Spotting job scams and fake offers

Spot job scams and fake offers by their signs, from fee demands to look-alike email domains, verify the role before you share documents, and report fast.

  • Beginner
  • 20 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free: 20 minutes

What you will learn

  • Recognise a job scam by its signs, starting with any request for money
  • Sort a message into scam, verify first or likely genuine, and name its red flags
  • Check a sender's domain against the employer's own, even when DMARC passes
  • Verify a role and an overseas offer before you share any documents
  • Report a scam through the right channel, and act at once if you paid
What you’ll learn

What you will learn

  • Recognise a job scam by its signs, starting with any request for money
  • Sort a message into scam, verify first or likely genuine, and name its red flags
  • Check a sender's domain against the employer's own, even when DMARC passes
  • Verify a role and an overseas offer before you share any documents
  • Report a scam through the right channel, and act at once if you paid
On this page

A message offers you paid online work, liking videos or rating products from your phone, and a week later asks for a deposit before you can withdraw what you have earned. The US Federal Trade Commission received about 20,000 reports of this kind of scam in six months, four times as many as in the whole year before. Job scams work because they copy the first steps of real hiring: a recruiter, an interview, an offer letter with a logo.

One rule catches most of them: an honest employer never asks you for money. This lesson adds the other signs to look for, a check of the sender’s domain that you can run, and where to report a scam in India, the US and elsewhere.

Not affiliated

MySmartCoPilot is independent of Wipro and of every other organisation named here; each rule quoted comes from the organisation’s own pages, listed under References. Tessera Labs and HireWell are invented for this lesson, and their domains end in .example, a name reserved for examples.

Any request for money marks the offer as a scam

Rohan has applied to a dozen companies when a message says he is selected for a backend role and must pay a refundable deposit of 4,999 for his laptop before he joins. The deposit is the whole scam. The FTC’s rule is short: honest employers, the federal government included, never ask you to pay to get a job, and anyone who does is a scammer. Employers say the same of themselves. Wipro says it does not charge any fee at any stage of recruitment, has authorised no agency to collect one, and that fake offers ask for a security deposit with a promise to return it after you join. India’s National Career Service, the government’s job portal, charges no fee at any stage and tells job seekers not to pay fees to employers.

Step through how such an offer unfolds, and notice how late the request for money comes:

Interactive · message by message A fake offer that ends in fees, one message at a time Step through the messages. Each step names the sign that shows, and the request for money is where the scam gives itself away.
A fake offer that ends in fees, one message at a time, step 8 of 8. Messages so far: Fake recruiter to You: You're shortlisted!; Fake recruiter to You: answer 5 quick questions; Fake recruiter to You: offer letter.pdf; Fake recruiter to You: pay a 4,999 deposit; You to Your bank: transfer 4,999; Your bank to Fake recruiter: 4,999 received; Fake recruiter to You: verification fee 7,500.Fake recruitera number you do not knowYoulooking for a jobYour bankor payment appYou're shortlisted!answer 5 quick questionsoffer letter.pdfpay a 4,999 deposittransfer 4,9994,999 receivedverification fee 7,500number no longer connected

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Out of the blue. A chat message from an unknown number says your profile is shortlisted for a remote developer role at Tessera Labs, at pay well above the market. You never applied. Unexpected contact about a job you did not seek is the first sign.
  2. An interview by chat. The interview is five questions typed into the chat. Employers such as Wipro say they never interview over instant messaging apps, and that their hiring includes at least one interview, virtual or in person.
  3. An offer at once. Minutes later an offer letter arrives as a PDF, with the company's logo and a signature. A logo costs nothing to copy, so the letter proves nothing about who sent it.
  4. The request for money. To confirm your seat you are asked for a refundable security deposit of 4,999 for a laptop. This is the moment the scam shows itself, because an employer that hires you does not charge you to be hired.
  5. The payment. If you pay, the money goes to an account the scammer controls. Stopping here costs nothing, because no genuine offer depends on a payment from you.
  6. The money moves on. The bank sends the money to the account you named. From there it can be moved on quickly, which is why a payment should be reported at once.
  7. A second fee. A day later comes a new fee, for background verification or onboarding. Someone who paid once is likely to pay again, so the requests continue.
  8. Silence. Then the number stops answering and the website disappears, as the FTC describes for job scams. Everything you were promised was a way to collect the payments.

The money is asked for last, after a chat, a quick selection and a letter with a logo have built trust, so apply the rule at the moment money is asked for, however real everything before it looked. The fee takes many names: registration, training, a certification, a laptop or a uniform, background verification, visa processing or a refundable deposit. In a task scam: A job scam that pays small sums for simple online tasks, such as liking videos or rating products, then asks for deposits before you may go on or withdraw the balance shown, which is never paid out. it is a deposit you must make to complete the next set of tasks or to withdraw your earnings, and the FTC warns that this money does not come back. In a fake-cheque scam: A scam in which a new employer or buyer sends you a cheque and asks you to send part of the money on; the cheque bounces later, and you may owe your bank the full amount. you deposit a cheque and send part of it on; the cheque bounces, and you may owe the bank the full amount.

Paying because the deposit is refundable

“It is refundable, so it is safe” is the reasoning the scam relies on. A refundable deposit is still a payment to get a job, and the promise to return it is part of the script that Wipro describes. Decline, and keep the message as evidence.

The rule has one narrow exception, for jobs abroad recruited through a registered agent in India, which the section on verifying an offer covers.

Key idea

When a message asks you to pay anything to get or keep a job, stop: honest employers never charge candidates, whatever the fee is called.

Checkpoint

Check: Any request for money marks a scam

  1. Question 1 of 2 A message says you are selected for a backend role and asks for a refundable deposit of 4,999 for your laptop, returned with your first salary. What do you do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Decline and keep the message, because any payment to get a job marks a scam

    The FTC's rule covers every form of payment: honest employers never ask you to pay to get a job. A refundable deposit is a payment, and its promised refund is how the request is made to look safe.

  2. Question 2 of 2 Which of these requests mark a message about a job as a scam? Choose every one that does.

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • A cheque to deposit, keeping a share and sending the rest on
    • A registration fee before your application is considered
    • A deposit to complete the next set of paid online tasks

    A fee, a deposit for tasks and a cheque to pass on all move money from you to the scammer. Scheduling an interview does not, although the sender still has to be checked.

Other signs: chat-only hiring, instant offers, tasks and cheques

Many scams have not asked for money yet when you first see them, so the other signs matter. The FTC lists four signs of any scam: scammers pretend to be from an organisation you know, say there is a problem or a prize, pressure you to act at once, and tell you to pay in a specific way. In job scams they look like this:

The sign What it looks like What the source says
Contact out of the blue A text or WhatsApp message about online work you never asked for FTC: real employers never contact you that way
A chat-only interview Questions typed into WhatsApp or Telegram Wipro: it never interviews over messaging apps
An offer with no interview “You are selected” after a form or a chat Wipro: its hiring has at least one interview
Pay for easy tasks Commission for liking videos, then deposits FTC: paying to get paid is a sure sign of a scam
A cheque to pass on Deposit it, keep a share, send the rest FTC: the cheque is fake and will bounce
Pay far above the role A lot of money for little work, quickly FTC: work-from-home scams promise this
Pressure to decide now “Confirm today or lose the seat” FTC: scammers pressure you to act at once
Public webmail An offer from a gmail.com address Wipro: fake offers come from public email IDs

Here are three messages, with the signs each one shows:

  1. “Congratulations! Your profile is shortlisted for Data Entry Associate, work from home, 3,000 a day. Reply YES to join our Telegram group for the interview.” Contact out of the blue, a chat-only interview and pay far above the role: verify before you reply, and expect a fee to follow.
  2. “Dear candidate, you are selected for Software Engineer Trainee at Tessera Labs. Pay 2,500 for background verification by tonight to keep your seat.” No interview, a deadline and a fee: a scam on the fee alone.
  3. “Hello, Meghna from the Tessera Labs talent team. Thank you for applying for the backend role, reference 4471. Could we set up a 45-minute video interview this week? The role is on our careers page.” No sign shows, so it is likely genuine, and the sender’s address still deserves the check of the next section.

Checkpoint

Check: The other signs of a job scam

  1. Question 1 of 2 "Congratulations! Your profile is shortlisted for Data Entry Associate, work from home, 3,000 a day. Reply YES to join our Telegram group for the interview." How do you treat it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Verify before you reply, because it shows three signs though it has not asked for money yet

    Without a request for money it is not yet a proven scam, but every sign it shows points that way, so the safe verdict is to verify first and not to engage on the chat app.

  2. Question 2 of 2 Meghna's message thanks you for applying, gives the role's reference number and asks for a 45-minute video interview. Which sign does it show?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: None, though its sender's address still needs checking

    A message that follows an application, asks for a real interview and names the role on the careers site shows no sign. It is likely genuine, and checking the sender's domain makes sure.

Check the sender’s domain, even when DMARC passes

A scam that copies an employer’s name usually cannot send mail from that employer’s domain, so the address is the next thing to check. The display name, such as “Tessera HR”, is text anyone can type; what counts is the domain after the @. Take the employer’s domain from its own careers site, here tessera.example, and accept a sender only when its domain is exactly that or a subdomain ending in .tessera.example. A look-alike domain: A domain name made to resemble an employer's own, with a word added or a letter changed, such as tessera-careers.example or tesera.example for tessera.example. fails this test: a word added, as in tessera-careers.example, or a letter dropped, as in tesera.example.

Mail services also record whether a message passed DMARC: Domain-based Message Authentication, Reporting and Conformance: an email standard by which a receiving server checks that the domain in a message's From address authorised it. A pass says nothing about who owns that domain.. A pass means that the domain in the From address authorised the message. It says nothing about whether that domain is the employer’s: the DMARC standard says it does not deal with visually similar domain names, so a look-alike passes for itself. Read the result that your own mail service recorded in the Authentication-Results header. The standard for that header warns that its presence does not make its contents true, because a sender can add a forged one. Step through the checks on one message:

Interactive · stage by stage Checking one sender, from the address to the verdict Step through the checks on one message, From "Tessera HR" at hr@tessera-careers.example. Each step says what is read and why.
Checking one sender, from the address to the verdict, step 7 of 7. Showing Take the address (tessera-careers.example), The employer's domain? (no: another domain), Public webmail? (no), Close to the name? (yes: contains tessera), Your server's DMARC (pass, for the look-alike), The verdict. In focus: The verdict.yes: check DMARCTake the addresstessera-careers.exampleThe employer's domain?no: another domainPublic webmail?noClose to the name?yes: contains tesseraYour server's DMARCpass, for the look-alikeThe verdictscam despite the pass

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The address, not the name. The display name Tessera HR is text that anyone can type. What counts is the address after the @, here tessera-careers.example.
  2. Is it the employer's domain?. The employer's careers site uses tessera.example. The sender's domain is neither tessera.example nor a subdomain ending in .tessera.example, so the check fails.
  3. The shortcut for real mail. Had the domain been the employer's, the next question would be only whether DMARC passed, which is the branch on the right.
  4. Public webmail?. The domain is not a public webmail service. An offer from gmail.com or outlook.com would fail here, because employers send offers from their own domain.
  5. Close to the employer's name?. The domain contains tessera, the employer's name, with a word added. That makes it a look-alike, made to be read at a glance as the real thing.
  6. What DMARC says. Your own mail server recorded DMARC pass. A pass only says that tessera-careers.example authorised the message; DMARC does not deal with names that look like another's.
  7. The verdict. A look-alike domain is a scam whatever DMARC says. The pass made the message look trustworthy, which is why the domain is checked before the result.

The program runs the same checks on five messages, each with the DMARC result its mail server recorded. It compares each domain with tessera.example, flags public webmail, and measures how far a domain’s first label is from the employer’s name as an edit distance: The fewest single-letter changes, insertions or deletions that turn one word into another; tesera is one edit away from tessera., the number of letters you would change, add or remove to turn one into the other. Each tab holds the same program, and all 14 printed the same lines:

One program · 14 languages Five senders checked against the employer's domain O(m × n) for labels of m and n letters

Python 3.14 · main.py

OFFICIAL = "tessera.example"
WEBMAIL = ("gmail.com", "outlook.com")

SENDERS = [
    ("meghna@tessera.example", "pass"),
    ("hr@tessera-careers.example", "pass"),
    ("offers@tesera.example", "none"),
    ("tessera.hiring@gmail.com", "pass"),
    ("meghna@tessera.example", "fail"),
]


def distance(a: str, b: str) -> int:
    d = [[i + j for j in range(len(b) + 1)] for i in range(len(a) + 1)]
    for i in range(1, len(a) + 1):
        for j in range(1, len(b) + 1):
            change = d[i - 1][j - 1] + (a[i - 1] != b[j - 1])
            d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1, change)
    return d[len(a)][len(b)]


def assess(domain: str, result: str) -> str:
    brand = OFFICIAL.split(".")[0]
    edits = distance(domain.split(".")[0], brand)
    if domain == OFFICIAL or domain.endswith("." + OFFICIAL):
        verdict = "checks out" if result == "pass" else "forged"
        return f"the employer's domain: {verdict}"
    if domain in WEBMAIL:
        return "public webmail: not the employer"
    if brand in domain:
        return f"look-alike, contains {brand}: scam"
    if edits <= 2:
        return f"look-alike, edit distance {edits}: scam"
    return "another domain: ask the employer"


for sender, result in SENDERS:
    print(f"{sender}, DMARC {result}")
    print(f"  {assess(sender.rpartition('@')[2].lower(), result)}")

In Python: (a[i - 1] != b[j - 1]) adds a bool to an int, and True counts as 1, so a mismatch costs one edit. sender.rpartition('@')[2] is the text after the last @.

Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py

JavaScript Node 24 · main.mjs

const OFFICIAL = "tessera.example";
const WEBMAIL = ["gmail.com", "outlook.com"];

const SENDERS = [
  ["meghna@tessera.example", "pass"],
  ["hr@tessera-careers.example", "pass"],
  ["offers@tesera.example", "none"],
  ["tessera.hiring@gmail.com", "pass"],
  ["meghna@tessera.example", "fail"],
];

function distance(a, b) {
  const d = Array.from({ length: a.length + 1 }, (_, i) =>
    Array.from({ length: b.length + 1 }, (_, j) => i + j),
  );
  for (let i = 1; i <= a.length; i++) {
    for (let j = 1; j <= b.length; j++) {
      const change = d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
      d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, change);
    }
  }
  return d[a.length][b.length];
}

function assess(domain, result) {
  const brand = OFFICIAL.split(".")[0];
  const edits = distance(domain.split(".")[0], brand);
  if (domain === OFFICIAL || domain.endsWith("." + OFFICIAL)) {
    return `the employer's domain: ${result === "pass" ? "checks out" : "forged"}`;
  }
  if (WEBMAIL.includes(domain)) return "public webmail: not the employer";
  if (domain.includes(brand)) return `look-alike, contains ${brand}: scam`;
  if (edits <= 2) return `look-alike, edit distance ${edits}: scam`;
  return "another domain: ask the employer";
}

for (const [sender, result] of SENDERS) {
  console.log(`${sender}, DMARC ${result}`);
  console.log(`  ${assess(sender.slice(sender.lastIndexOf("@") + 1).toLowerCase(), result)}`);
}

In JavaScript: Array.from with a mapping function builds each row of the table already filled with i + j, and a[i - 1] reads one character of the label.

Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs

Java 25 LTS · Main.java

import java.util.List;
import java.util.Locale;

public class Main {
  static final String OFFICIAL = "tessera.example";
  static final List<String> WEBMAIL = List.of("gmail.com", "outlook.com");

  record Sender(String address, String result) {}

  static final List<Sender> SENDERS =
      List.of(
          new Sender("meghna@tessera.example", "pass"),
          new Sender("hr@tessera-careers.example", "pass"),
          new Sender("offers@tesera.example", "none"),
          new Sender("tessera.hiring@gmail.com", "pass"),
          new Sender("meghna@tessera.example", "fail"));

  static int distance(String a, String b) {
    int[][] d = new int[a.length() + 1][b.length() + 1];
    for (int i = 0; i <= a.length(); i++) {
      for (int j = 0; j <= b.length(); j++) {
        d[i][j] = i + j;
        if (i > 0 && j > 0) {
          int change = d[i - 1][j - 1] + (a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1);
          d[i][j] = Math.min(Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1), change);
        }
      }
    }
    return d[a.length()][b.length()];
  }

  static String assess(String domain, String result) {
    String brand = OFFICIAL.split("\\.")[0];
    int edits = distance(domain.split("\\.")[0], brand);
    if (domain.equals(OFFICIAL) || domain.endsWith("." + OFFICIAL)) {
      return "the employer's domain: " + (result.equals("pass") ? "checks out" : "forged");
    } else if (WEBMAIL.contains(domain)) {
      return "public webmail: not the employer";
    } else if (domain.contains(brand)) {
      return "look-alike, contains " + brand + ": scam";
    } else if (edits <= 2) {
      return "look-alike, edit distance " + edits + ": scam";
    }
    return "another domain: ask the employer";
  }

  public static void main(String[] args) {
    for (Sender s : SENDERS) {
      String domain = s.address().substring(s.address().lastIndexOf('@') + 1);
      System.out.println(s.address() + ", DMARC " + s.result());
      System.out.println("  " + assess(domain.toLowerCase(Locale.ROOT), s.result()));
    }
  }
}

In Java: toLowerCase(Locale.ROOT) lowercases the same way on every device; plain toLowerCase() follows the device's language and turns I into a dotless ı on a Turkish system.

Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java

C++23 · main.cpp

#include <algorithm>
#include <array>
#include <cctype>
#include <format>
#include <print>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

constexpr std::string_view official = "tessera.example";
constexpr std::array<std::string_view, 2> webmail = {"gmail.com",
                                                     "outlook.com"};

constexpr std::array<std::pair<std::string_view, std::string_view>, 5> senders =
    {{{"meghna@tessera.example", "pass"},
      {"hr@tessera-careers.example", "pass"},
      {"offers@tesera.example", "none"},
      {"tessera.hiring@gmail.com", "pass"},
      {"meghna@tessera.example", "fail"}}};

int distance(std::string_view a, std::string_view b) {
  std::vector d(a.size() + 1, std::vector<int>(b.size() + 1));
  for (std::size_t i = 0; i <= a.size(); i++) {
    for (std::size_t j = 0; j <= b.size(); j++) {
      d[i][j] = static_cast<int>(i + j);
      if (i > 0 && j > 0) {
        const int change = d[i - 1][j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1);
        d[i][j] = std::min({d[i - 1][j] + 1, d[i][j - 1] + 1, change});
      }
    }
  }
  return d[a.size()][b.size()];
}

std::string assess(std::string_view domain, std::string_view result) {
  const auto brand = official.substr(0, official.find('.'));
  const int edits = distance(domain.substr(0, domain.find('.')), brand);
  if (domain == official || domain.ends_with(std::format(".{}", official))) {
    return std::format("the employer's domain: {}",
                       result == "pass" ? "checks out" : "forged");
  }
  if (std::ranges::contains(webmail, domain)) {
    return "public webmail: not the employer";
  }
  if (domain.contains(brand)) {
    return std::format("look-alike, contains {}: scam", brand);
  }
  if (edits <= 2) {
    return std::format("look-alike, edit distance {}: scam", edits);
  }
  return "another domain: ask the employer";
}

int main() {
  for (const auto &[address, result] : senders) {
    std::string domain{address.substr(address.rfind('@') + 1)};
    std::ranges::transform(domain, domain.begin(),
                           [](unsigned char c) { return std::tolower(c); });
    std::println("{}, DMARC {}", address, result);
    std::println("  {}", assess(domain, result));
  }
}

In C++: std::ranges::contains and string_view::contains are new in C++23, and ends_with in C++20. std::tolower is given each character as an unsigned char, as it requires.

Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main

C# 14 · .NET 10 · app.cs

const string Official = "tessera.example";

(string Address, string Result)[] senders =
[
    ("meghna@tessera.example", "pass"),
    ("hr@tessera-careers.example", "pass"),
    ("offers@tesera.example", "none"),
    ("tessera.hiring@gmail.com", "pass"),
    ("meghna@tessera.example", "fail"),
];

foreach (var (address, result) in senders)
{
    string domain = address[(address.LastIndexOf('@') + 1)..].ToLowerInvariant();
    Console.WriteLine($"{address}, DMARC {result}");
    Console.WriteLine($"  {Assess(domain, result)}");
}

static int Distance(string a, string b)
{
    var d = new int[a.Length + 1, b.Length + 1];
    for (int i = 0; i <= a.Length; i++)
    {
        for (int j = 0; j <= b.Length; j++)
        {
            d[i, j] = i + j;
            if (i > 0 && j > 0)
            {
                int change = d[i - 1, j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1);
                d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), change);
            }
        }
    }
    return d[a.Length, b.Length];
}

static string Assess(string domain, string result)
{
    string brand = Official.Split('.')[0];
    int edits = Distance(domain.Split('.')[0], brand);
    return domain switch
    {
        _ when domain == Official || domain.EndsWith("." + Official, StringComparison.Ordinal) =>
            "the employer's domain: " + (result == "pass" ? "checks out" : "forged"),
        "gmail.com" or "outlook.com" => "public webmail: not the employer",
        _ when domain.Contains(brand, StringComparison.Ordinal) =>
            $"look-alike, contains {brand}: scam",
        _ when edits <= 2 => $"look-alike, edit distance {edits}: scam",
        _ => "another domain: ask the employer",
    };
}

In C#: The webmail domains are a pattern in the switch expression, "gmail.com" or "outlook.com": a C# pattern takes constants, and an array cannot be one.

Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs

C23 · main.c

#include <ctype.h>
#include <stdio.h>
#include <string.h>

static const char OFFICIAL[] = "tessera.example";
static const char *const WEBMAIL[] = {"gmail.com", "outlook.com"};

static const char *const SENDERS[][2] = {
    {"meghna@tessera.example", "pass"}, {"hr@tessera-careers.example", "pass"},
    {"offers@tesera.example", "none"},  {"tessera.hiring@gmail.com", "pass"},
    {"meghna@tessera.example", "fail"},
};

static int distance(const char *a, size_t n, const char *b, size_t m) {
  int d[64][64];
  for (size_t i = 0; i <= n; i++) {
    for (size_t j = 0; j <= m; j++) {
      d[i][j] = (int)(i + j);
      if (i > 0 && j > 0) {
        int change = d[i - 1][j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1);
        int shorter = d[i - 1][j] < d[i][j - 1] ? d[i - 1][j] : d[i][j - 1];
        d[i][j] = shorter + 1 < change ? shorter + 1 : change;
      }
    }
  }
  return d[n][m];
}

static void assess(const char *domain, const char *result) {
  char brand[64];
  snprintf(brand, sizeof brand, "%.*s", (int)strcspn(OFFICIAL, "."), OFFICIAL);
  size_t label = strcspn(domain, "."), length = strlen(domain);
  int edits = distance(domain, label < 63 ? label : 63, brand, strlen(brand));
  size_t tail = strlen(OFFICIAL);
  bool subdomain = length > tail && domain[length - tail - 1] == '.' &&
                   strcmp(domain + length - tail, OFFICIAL) == 0;
  bool webmail = false;
  for (size_t i = 0; i < sizeof WEBMAIL / sizeof WEBMAIL[0]; i++)
    webmail = webmail || strcmp(domain, WEBMAIL[i]) == 0;
  if (strcmp(domain, OFFICIAL) == 0 || subdomain)
    printf("  the employer's domain: %s\n",
           strcmp(result, "pass") == 0 ? "checks out" : "forged");
  else if (webmail)
    printf("  public webmail: not the employer\n");
  else if (strstr(domain, brand) != nullptr)
    printf("  look-alike, contains %s: scam\n", brand);
  else if (edits <= 2)
    printf("  look-alike, edit distance %d: scam\n", edits);
  else
    printf("  another domain: ask the employer\n");
}

int main(void) {
  for (size_t i = 0; i < sizeof SENDERS / sizeof SENDERS[0]; i++) {
    const char *at = strrchr(SENDERS[i][0], '@');
    char domain[256];
    snprintf(domain, sizeof domain, "%s",
             at != nullptr ? at + 1 : SENDERS[i][0]);
    for (char *c = domain; *c != '\0'; c++)
      *c = (char)tolower((unsigned char)*c);
    printf("%s, DMARC %s\n", SENDERS[i][0], SENDERS[i][1]);
    assess(domain, SENDERS[i][1]);
  }
}

In C: C strings cannot grow, so the domain is copied into a 256-byte buffer to be lowercased, and the table is a fixed 64 by 64, because a DNS label has at most 63 characters.

Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main

PHP 8.5 · main.php

<?php

declare(strict_types=1);

const OFFICIAL = 'tessera.example';
const WEBMAIL = ['gmail.com', 'outlook.com'];

const SENDERS = [
    ['meghna@tessera.example', 'pass'],
    ['hr@tessera-careers.example', 'pass'],
    ['offers@tesera.example', 'none'],
    ['tessera.hiring@gmail.com', 'pass'],
    ['meghna@tessera.example', 'fail'],
];

function distance(string $a, string $b): int
{
    $d = [];
    for ($i = 0; $i <= strlen($a); $i++) {
        for ($j = 0; $j <= strlen($b); $j++) {
            $d[$i][$j] = $i + $j;
            if ($i > 0 && $j > 0) {
                $change = $d[$i - 1][$j - 1] + ($a[$i - 1] === $b[$j - 1] ? 0 : 1);
                $d[$i][$j] = min($d[$i - 1][$j] + 1, $d[$i][$j - 1] + 1, $change);
            }
        }
    }
    return $d[strlen($a)][strlen($b)];
}

function assess(string $domain, string $result): string
{
    $brand = explode('.', OFFICIAL)[0];
    $edits = distance(explode('.', $domain)[0], $brand);
    if ($domain === OFFICIAL || str_ends_with($domain, '.' . OFFICIAL)) {
        return "the employer's domain: " . ($result === 'pass' ? 'checks out' : 'forged');
    }
    if (in_array($domain, WEBMAIL, true)) {
        return 'public webmail: not the employer';
    }
    if (str_contains($domain, $brand)) {
        return "look-alike, contains {$brand}: scam";
    }
    if ($edits <= 2) {
        return "look-alike, edit distance {$edits}: scam";
    }
    return 'another domain: ask the employer';
}

foreach (SENDERS as [$sender, $result]) {
    $domain = strtolower(substr($sender, strrpos($sender, '@') + 1));
    echo "{$sender}, DMARC {$result}\n";
    echo '  ' . assess($domain, $result) . "\n";
}

In PHP: PHP's built-in levenshtein($a, $b) returns the same distance in one call; the loop is written out so that every language shows the same table.

Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php

Rust 1.99 · main.rs

const OFFICIAL: &str = "tessera.example";
const WEBMAIL: [&str; 2] = ["gmail.com", "outlook.com"];

const SENDERS: [(&str, &str); 5] = [
    ("meghna@tessera.example", "pass"),
    ("hr@tessera-careers.example", "pass"),
    ("offers@tesera.example", "none"),
    ("tessera.hiring@gmail.com", "pass"),
    ("meghna@tessera.example", "fail"),
];

fn distance(a: &str, b: &str) -> usize {
    let (a, b) = (a.as_bytes(), b.as_bytes());
    let mut d: Vec<Vec<usize>> = (0..=a.len())
        .map(|i| (0..=b.len()).map(|j| i + j).collect())
        .collect();
    for i in 1..=a.len() {
        for j in 1..=b.len() {
            let change = d[i - 1][j - 1] + usize::from(a[i - 1] != b[j - 1]);
            d[i][j] = (d[i - 1][j] + 1).min(d[i][j - 1] + 1).min(change);
        }
    }
    d[a.len()][b.len()]
}

fn assess(domain: &str, result: &str) -> String {
    let brand = OFFICIAL.split('.').next().unwrap_or(OFFICIAL);
    let edits = distance(domain.split('.').next().unwrap_or(domain), brand);
    if domain == OFFICIAL || domain.ends_with(&format!(".{OFFICIAL}")) {
        let verdict = if result == "pass" {
            "checks out"
        } else {
            "forged"
        };
        format!("the employer's domain: {verdict}")
    } else if WEBMAIL.contains(&domain) {
        String::from("public webmail: not the employer")
    } else if domain.contains(brand) {
        format!("look-alike, contains {brand}: scam")
    } else if edits <= 2 {
        format!("look-alike, edit distance {edits}: scam")
    } else {
        String::from("another domain: ask the employer")
    }
}

fn main() {
    for (sender, result) in SENDERS {
        let domain = sender.rsplit('@').next().unwrap_or(sender).to_lowercase();
        println!("{sender}, DMARC {result}");
        println!("  {}", assess(&domain, result));
    }
}

In Rust: The table compares bytes, which is exact for these ASCII domains. rsplit('@').next() is the text after the last @, or the whole text when there is none; next() returns an Option all the same, and unwrap_or takes its value without a panic.

Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main

TypeScript 6.0 · main.ts

const OFFICIAL = "tessera.example";
const WEBMAIL: readonly string[] = ["gmail.com", "outlook.com"];

const SENDERS: [address: string, result: string][] = [
  ["meghna@tessera.example", "pass"],
  ["hr@tessera-careers.example", "pass"],
  ["offers@tesera.example", "none"],
  ["tessera.hiring@gmail.com", "pass"],
  ["meghna@tessera.example", "fail"],
];

function distance(a: string, b: string): number {
  const d = Array.from({ length: a.length + 1 }, (_, i) =>
    Array.from({ length: b.length + 1 }, (_, j) => i + j),
  );
  for (let i = 1; i <= a.length; i++) {
    for (let j = 1; j <= b.length; j++) {
      const change = d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
      d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, change);
    }
  }
  return d[a.length][b.length];
}

function assess(domain: string, result: string): string {
  const brand = OFFICIAL.split(".")[0];
  const edits = distance(domain.split(".")[0], brand);
  if (domain === OFFICIAL || domain.endsWith("." + OFFICIAL)) {
    return `the employer's domain: ${result === "pass" ? "checks out" : "forged"}`;
  }
  if (WEBMAIL.includes(domain)) return "public webmail: not the employer";
  if (domain.includes(brand)) return `look-alike, contains ${brand}: scam`;
  if (edits <= 2) return `look-alike, edit distance ${edits}: scam`;
  return "another domain: ask the employer";
}

for (const [sender, result] of SENDERS) {
  console.log(`${sender}, DMARC ${result}`);
  console.log(`  ${assess(sender.slice(sender.lastIndexOf("@") + 1).toLowerCase(), result)}`);
}

In TypeScript: The tuple type [address: string, result: string] names the two fields of each sender, so the compiler checks every row of the list.

Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts

Go 1.27 · main.go

package main

import (
	"fmt"
	"slices"
	"strings"
)

const official = "tessera.example"

var webmail = []string{"gmail.com", "outlook.com"}

var senders = [][2]string{
	{"meghna@tessera.example", "pass"},
	{"hr@tessera-careers.example", "pass"},
	{"offers@tesera.example", "none"},
	{"tessera.hiring@gmail.com", "pass"},
	{"meghna@tessera.example", "fail"},
}

func distance(a, b string) int {
	d := make([][]int, len(a)+1)
	for i := range d {
		d[i] = make([]int, len(b)+1)
		for j := range d[i] {
			d[i][j] = i + j
			if i > 0 && j > 0 {
				change := d[i-1][j-1]
				if a[i-1] != b[j-1] {
					change++
				}
				d[i][j] = min(d[i-1][j]+1, d[i][j-1]+1, change)
			}
		}
	}
	return d[len(a)][len(b)]
}

func assess(domain, result string) string {
	brand, _, _ := strings.Cut(official, ".")
	label, _, _ := strings.Cut(domain, ".")
	edits := distance(label, brand)
	switch {
	case domain == official || strings.HasSuffix(domain, "."+official):
		if result == "pass" {
			return "the employer's domain: checks out"
		}
		return "the employer's domain: forged"
	case slices.Contains(webmail, domain):
		return "public webmail: not the employer"
	case strings.Contains(domain, brand):
		return fmt.Sprintf("look-alike, contains %s: scam", brand)
	case edits <= 2:
		return fmt.Sprintf("look-alike, edit distance %d: scam", edits)
	}
	return "another domain: ask the employer"
}

func main() {
	for _, s := range senders {
		domain := strings.ToLower(s[0][strings.LastIndexByte(s[0], '@')+1:])
		fmt.Printf("%s, DMARC %s\n", s[0], s[1])
		fmt.Printf("  %s\n", assess(domain, s[1]))
	}
}

In Go: strings.Cut returns the text before the first dot. Go has no conditional expression, so a mismatch adds one to the cost with an if, and min takes the three costs at once.

Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go

Swift 6.4 · main.swift

let official = "tessera.example"
let webmail = ["gmail.com", "outlook.com"]

let senders: [(address: String, result: String)] = [
  ("meghna@tessera.example", "pass"),
  ("hr@tessera-careers.example", "pass"),
  ("offers@tesera.example", "none"),
  ("tessera.hiring@gmail.com", "pass"),
  ("meghna@tessera.example", "fail"),
]

func distance(_ a: String, _ b: String) -> Int {
  let (a, b) = (Array(a), Array(b))
  var d = (0...a.count).map { i in (0...b.count).map { j in i + j } }
  for i in stride(from: 1, through: a.count, by: 1) {
    for j in stride(from: 1, through: b.count, by: 1) {
      let change = d[i - 1][j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1)
      d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1, change)
    }
  }
  return d[a.count][b.count]
}

func occurs(_ part: String, in text: String) -> Bool {
  let (part, text) = (Array(part), Array(text))
  guard part.count <= text.count else { return false }
  return (0...(text.count - part.count)).contains { Array(text[$0..<$0 + part.count]) == part }
}

func assess(domain: String, result: String) -> String {
  let brand = String(official.split(separator: ".")[0])
  let label = String(domain.split(separator: ".", omittingEmptySubsequences: false)[0])
  let edits = distance(label, brand)
  if domain == official || domain.hasSuffix("." + official) {
    return "the employer's domain: " + (result == "pass" ? "checks out" : "forged")
  }
  if webmail.contains(domain) {
    return "public webmail: not the employer"
  }
  if occurs(brand, in: domain) {
    return "look-alike, contains \(brand): scam"
  }
  if edits <= 2 {
    return "look-alike, edit distance \(edits): scam"
  }
  return "another domain: ask the employer"
}

for sender in senders {
  let domain = sender.address.split(separator: "@", omittingEmptySubsequences: false).last ?? ""
  print("\(sender.address), DMARC \(sender.result)")
  print("  \(assess(domain: String(domain).lowercased(), result: sender.result))")
}

In Swift: occurs searches for the name itself, because String's contains(_:) for text comes from Swift's regex module, which makes the build for the browser too large. Array(a) gives the table integer indices.

Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main

Ruby 4.0 · main.rb

# frozen_string_literal: true

OFFICIAL = 'tessera.example'
WEBMAIL = %w[gmail.com outlook.com].freeze

SENDERS = [
  ['meghna@tessera.example', 'pass'],
  ['hr@tessera-careers.example', 'pass'],
  ['offers@tesera.example', 'none'],
  ['tessera.hiring@gmail.com', 'pass'],
  ['meghna@tessera.example', 'fail']
].freeze

# One row of the table, from the row above it and one letter of the first label.
def next_row(above, letter, second)
  second.each_char.with_index.with_object([above.first + 1]) do |(other, j), row|
    row << [above[j + 1] + 1, row[j] + 1, above[j] + (letter == other ? 0 : 1)].min
  end
end

def distance(first, second)
  row = (0..second.length).to_a
  first.each_char { |letter| row = next_row(row, letter, second) }
  row.last
end

def assess(domain, result)
  brand = OFFICIAL.split('.').first
  edits = distance(domain.split('.').first, brand)
  if domain == OFFICIAL || domain.end_with?(".#{OFFICIAL}")
    "the employer's domain: #{result == 'pass' ? 'checks out' : 'forged'}"
  elsif WEBMAIL.include?(domain) then 'public webmail: not the employer'
  elsif domain.include?(brand) then "look-alike, contains #{brand}: scam"
  elsif edits <= 2 then "look-alike, edit distance #{edits}: scam"
  else 'another domain: ask the employer'
  end
end

SENDERS.each do |sender, result|
  puts "#{sender}, DMARC #{result}"
  puts "  #{assess(sender.rpartition('@').last.downcase, result)}"
end

In Ruby: RuboCop limits the size of a method, so the table is built one row at a time; next_row computes each row from the row above it, by the same rule as the full table.

Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb

Kotlin 2.4 · Main.kt

const val OFFICIAL = "tessera.example"
val webmail = listOf("gmail.com", "outlook.com")

val senders =
    listOf(
        "meghna@tessera.example" to "pass",
        "hr@tessera-careers.example" to "pass",
        "offers@tesera.example" to "none",
        "tessera.hiring@gmail.com" to "pass",
        "meghna@tessera.example" to "fail",
    )

fun distance(
    a: String,
    b: String,
): Int {
    val d = Array(a.length + 1) { i -> IntArray(b.length + 1) { j -> i + j } }
    for (i in 1..a.length) {
        for (j in 1..b.length) {
            val change = d[i - 1][j - 1] + if (a[i - 1] == b[j - 1]) 0 else 1
            d[i][j] = minOf(d[i - 1][j] + 1, d[i][j - 1] + 1, change)
        }
    }
    return d[a.length][b.length]
}

fun assess(
    domain: String,
    result: String,
): String {
    val brand = OFFICIAL.substringBefore('.')
    val edits = distance(domain.substringBefore('.'), brand)
    val verdict = if (result == "pass") "checks out" else "forged"
    return when {
        domain == OFFICIAL || domain.endsWith(".$OFFICIAL") -> "the employer's domain: $verdict"
        domain in webmail -> "public webmail: not the employer"
        brand in domain -> "look-alike, contains $brand: scam"
        edits <= 2 -> "look-alike, edit distance $edits: scam"
        else -> "another domain: ask the employer"
    }
}

fun main() {
    for ((sender, result) in senders) {
        println("$sender, DMARC $result")
        println("  ${assess(sender.substringAfterLast('@').lowercase(), result)}")
    }
}

In Kotlin: substringBefore('.') and substringAfterLast('@') return the whole text when the delimiter is missing, so a bare label or an address needs no special case.

Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar

Dart 3.13 · main.dart

import 'dart:math';

const official = 'tessera.example';
const webmail = ['gmail.com', 'outlook.com'];

const senders = [
  ('meghna@tessera.example', 'pass'),
  ('hr@tessera-careers.example', 'pass'),
  ('offers@tesera.example', 'none'),
  ('tessera.hiring@gmail.com', 'pass'),
  ('meghna@tessera.example', 'fail'),
];

int distance(String a, String b) {
  final d = List.generate(
    a.length + 1,
    (i) => List.generate(b.length + 1, (j) => i + j),
  );
  for (var i = 1; i <= a.length; i++) {
    for (var j = 1; j <= b.length; j++) {
      final change = d[i - 1][j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1);
      d[i][j] = min(min(d[i - 1][j] + 1, d[i][j - 1] + 1), change);
    }
  }
  return d[a.length][b.length];
}

String assess(String domain, String result) {
  final brand = official.split('.').first;
  final edits = distance(domain.split('.').first, brand);
  if (domain == official || domain.endsWith('.$official')) {
    return "the employer's domain: ${result == 'pass' ? 'checks out' : 'forged'}";
  }
  if (webmail.contains(domain)) return 'public webmail: not the employer';
  if (domain.contains(brand)) return 'look-alike, contains $brand: scam';
  if (edits <= 2) return 'look-alike, edit distance $edits: scam';
  return 'another domain: ask the employer';
}

void main() {
  for (final (sender, result) in senders) {
    final domain = sender.substring(sender.lastIndexOf('@') + 1).toLowerCase();
    print('$sender, DMARC $result');
    print('  ${assess(domain, result)}');
  }
}

In Dart: The loop takes each record apart with final (sender, result), and min from dart:math takes two costs at a time.

Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart

What every language printed

meghna@tessera.example, DMARC pass
  the employer's domain: checks out
hr@tessera-careers.example, DMARC pass
  look-alike, contains tessera: scam
offers@tesera.example, DMARC none
  look-alike, edit distance 1: scam
tessera.hiring@gmail.com, DMARC pass
  public webmail: not the employer
meghna@tessera.example, DMARC fail
  the employer's domain: forged

Read messages 2 and 5 together. Message 2 passed DMARC and is still a scam, because its domain only resembles the employer’s. Message 5 uses the employer’s real address and failed DMARC, which is how forged mail looks: nothing shows that the domain’s owner sent it. A sender on another domain, such as a recruiting agency’s own, can be genuine, so the program’s last rule says to ask the employer, through a contact on its careers site, whether the agency works for it. Look at the Reply-To header too: a reply that would go to a webmail address instead of the sender’s domain is one more reason to stop.

Email Header Analyzer Paste the headers of a message you received to read its From, Reply-To and Authentication-Results lines.

Key idea

Check the domain before the DMARC result: a pass proves only that a domain sent the message, so a look-alike domain is a scam even when it passes.

Checkpoint

Check: Checking the sender's domain

  1. Question 1 of 2 An offer comes from hr@tessera-careers.example, and your mail app shows DMARC pass. The employer's careers site uses tessera.example. What does the pass tell you?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Only that tessera-careers.example authorised the message; the domain is a look-alike, so it is a scam

    The program's message 2 is this case: a pass, and still a scam. Check the domain first; DMARC only tells you whether the domain you see really sent the message.

  2. Question 2 of 2 The employer's careers site uses tessera.example. Which of these senders are on the employer's domain? Choose every one that is.

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • jobs@careers.tessera.example
    • meghna@tessera.example

    A sender is on the employer's domain when its domain equals tessera.example or ends in .tessera.example, with the dot. Names that only contain or end with the letters are other domains.

Verify the role and the person before you share documents

Before you reply with anything personal, confirm that the job exists and that the person works for the employer. Find the role on the employer’s own careers site, which Wipro names as the place to verify an offer, and contact the employer through an address or a phone number taken from that site, never from the message. A genuine offer comes as documents to review and sign from the employer’s recruiting team; Microsoft describes its own offers that way. What you share depends on how far a verified process has gone:

They ask for A genuine employer asks Until then
Money of any kind Never Refuse
An OTP, a password or a card number Never Refuse
Bank details or tax forms After a verified written offer, for payroll Refuse: Wipro’s recruiters never ask for them
Copies of your ID and certificates For a background check, after a verified offer Share them only through the employer’s own channel

Offers of jobs abroad add checks of their own, because the travel and the visa are part of the offer.

Jobs abroad recruited from India: the agent, the contract and the visa

The Ministry of External Affairs asks job seekers to use only a registered recruiting agent: In India, an agency registered with the Ministry of External Affairs to recruit workers for jobs abroad. The list of active agents is published on the eMigrate portal. More in “Career switches, gaps, layoffs and roles abroad”, checked on the list of active agents on the eMigrate portal, whose licence number appears in its offices and adverts. Under the Emigration Act, 1983, such an agent may charge at most ₹30,000 plus 18 % GST, with a receipt. A genuine offer comes with an employment contract that the foreign employer, the agent and you all sign, and you travel on an employment or work visa; a tourist visa is never right for a job. The ministry warns that unregistered agents cheat job seekers with fake offers, overcharge them by ₹2–5 lakh and have led workers into life-threatening conditions, with cases reported in places such as Myanmar and Laos.

The special situations lesson covers the other checks of a role abroad, from visa sponsorship to who your employer would be.

Checkpoint

Check: Verifying the role before you share documents

  1. Question 1 of 2 A recruiter you have not verified yet asks for your bank account details "to set up your salary in advance". What do you do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Refuse until you hold a verified written offer, and verify the recruiter through the careers site

    What you share depends on how far a verified process has gone. Money, codes, passwords and card numbers are never shared; bank details and ID copies only after a verified written offer.

  2. Question 2 of 2 An agent in India offers a software job abroad and says you will travel on a tourist visa and change it after you arrive. What does the Ministry of External Affairs' advisory say?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A genuine offer comes with an employment or work visa, never a tourist visa

    The advisory asks for a registered agent, a contract that the foreign employer, the agent and you all sign, and an employment or work visa. A tourist visa for a job is a sign of illegal recruitment.

Report it at once, and faster if you paid

Once a message looks like a scam, the order of what you do decides how much you can save. Step through it, with the branch for when you paid nothing:

Interactive · stage by stage What to do when a job offer turns out to be a scam Step through the order that limits the loss. If you paid nothing, the branch skips the two money steps.
What to do when a job offer turns out to be a scam, step 7 of 7. Showing Stop replying and paying, Did you pay or share?, Call your bank or app, Report the fraud, Report the message, Tell the employer named. In focus: Tell the employer named.nothing paidStop replying and payingkeep every messageDid you pay or share?money, an OTP, card detailsCall your bank or appask to stop or reverse itReport the fraudIndia: 1930 or the portalReport the messageChakshu, the FTC, the appTell the employer namedits recruitment helpdesk

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Stop, and keep the evidence. Stop replying and do not pay again, whatever is promised. Keep the messages, the numbers, the payment receipts and the transaction IDs; every report below asks for them.
  2. Did anything leave your hands?. The next steps depend on whether you paid, shared a one-time password, or gave card or bank details. Anything you gave away is the first thing to protect.
  3. Nothing paid. If you paid nothing and shared no codes, go straight to reporting the message, so that the next person who receives it is warned sooner.
  4. Your bank first. If you paid, call your bank, card issuer or payment app at once and ask them to stop or reverse the payment, as the FTC advises for every way of paying.
  5. Report the fraud. In India, call 1930 or file at cybercrime.gov.in, the Home Ministry's portal, which was set up so that frauds are reported at once. In the US, report at ReportFraud.ftc.gov.
  6. Report the message. Report the number or the account to the app that carried it. In India, Chakshu on the Sanchar Saathi portal takes reports of fraud calls, texts and WhatsApp messages.
  7. Tell the employer. Tell the employer whose name was used, through the contact on its own careers site. Wipro, for example, asks for such messages at its recruitment helpdesk.

If you paid, call your bank or payment app first: the FTC tells anyone who paid a scammer to contact the company they used to send the money and ask for the payment to be reversed, whatever the method. Then report the fraud, in the US at ReportFraud.ftc.gov, and elsewhere to your country’s police cybercrime or fraud service. Report the number or the account to the app that carried the message, and tell the employer whose name was used, through the contact on its own careers site; Wipro asks for such messages at its recruitment helpdesk and, if you have paid, for a complaint at the nearest police station.

Reporting in India

Call 1930 or file a complaint at cybercrime.gov.in, the Ministry of Home Affairs’ National Cyber Crime Reporting Portal. Its system for financial fraud is built for reports made at once, so that the money can be stopped before it is moved on, and the ministry told Parliament that more than ₹8,690 crore had been saved in more than 24.65 lakh complaints. If you lost nothing, report the call, text or WhatsApp message on Chakshu, on the Department of Telecommunications’ Sanchar Saathi portal, within 30 days of receiving it.

Checkpoint

Check: Reporting a scam and limiting the loss

  1. Question 1 of 2 An hour ago you paid a "verification fee" to someone offering a job, and you now see it was a scam. What do you do first?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Call your bank or payment app and ask them to stop or reverse the payment

    Order matters: your bank first, because a payment can sometimes be stopped, then the fraud report (1930 or cybercrime.gov.in in India, ReportFraud.ftc.gov in the US), then the message and the employer.

  2. Question 2 of 2 You received a fake offer on WhatsApp in India, but you paid nothing and shared nothing. Where do you report the message?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: On Chakshu, on the Sanchar Saathi portal, within 30 days, and to the app

    Chakshu is for suspected fraud communication; 1930 and cybercrime.gov.in are for frauds in which money or details were lost. Report the account to the app as well.

Practise: sort the messages

Write this lesson’s triage rules as a function, then run the sample tests. They include a subdomain of the employer, a domain that only ends like the employer’s, and an offer from webmail without an interview:

Exercise · Easy · Python

Sort job messages into scam, verify first or likely genuine

A message about a job can be sorted from a few facts about it. Write triage(message), which returns a pair (verdict, flags): the verdict is "scam", "verify first" or "likely genuine", and flags is the list of the message's red flags, in the order below.

The message is a dictionary with these keys:

  • sender: the domain of the sender's address, such as "careers.tessera.example" or "gmail.com";
  • employer: the employer's own domain, taken from its careers site, such as "tessera.example";
  • asks_money: True when the message asks you to pay anything: a fee, a deposit or a purchase;
  • asks_secret: True when it asks for a one-time password, a password or a card number;
  • interview: "video", "in person", "chat" or "none";
  • on_careers_site: True when the role is listed on the employer's careers site.

The flags, in this order:

  1. "asks for money" when asks_money is true;
  2. "asks for a code or password" when asks_secret is true;
  3. "interview only by chat" when interview is "chat";
  4. "no interview" when interview is "none";
  5. "sender not on the employer's domain" unless the sender's domain is the employer's domain or a subdomain of it, compared in lower case (careers.tessera.example is on tessera.example; nottessera.example is not);
  6. "role not on the careers site" when on_careers_site is false.

The verdict is "scam" when the message asks for money or for a code or password, "verify first" when it has any other flag, and "likely genuine" when it has none.

Starter code · triage.py

"""Sort a message about a job into "scam", "verify first" or "likely genuine", with its red flags."""


def triage(message):
    """The verdict and the red flags of a message.

    >>> triage({"sender": "tessera.example", "employer": "tessera.example", "asks_money": False,
    ...         "asks_secret": False, "interview": "video", "on_careers_site": True})
    ('likely genuine', [])
    """
    # Write your solution here.
    return "likely genuine", []
The sample tests · test_triage.py
from triage import triage


def message(**changes):
    """A message from the employer's own domain, with an interview and the role listed, changed as asked."""
    base = {
        "sender": "tessera.example",
        "employer": "tessera.example",
        "asks_money": False,
        "asks_secret": False,
        "interview": "video",
        "on_careers_site": True,
    }
    base.update(changes)
    return base


def test_a_genuine_message():
    """the employer's domain, an interview and a listed role: no flags"""
    assert triage(message()) == ("likely genuine", [])


def test_a_subdomain_and_an_interview_in_person():
    """a subdomain of the employer's domain is the employer's"""
    assert triage(message(sender="careers.tessera.example", interview="in person")) == ("likely genuine", [])


def test_domains_ignore_case():
    """domain names are compared in lower case"""
    assert triage(message(sender="Careers.Tessera.EXAMPLE", employer="tessera.example")) == ("likely genuine", [])


def test_a_fee_makes_it_a_scam():
    """any request for money is a scam, even from the employer's domain"""
    assert triage(message(asks_money=True)) == ("scam", ["asks for money"])


def test_a_one_time_password_makes_it_a_scam():
    """asking for a code or a password is a scam"""
    assert triage(message(asks_secret=True, interview="chat")) == (
        "scam",
        ["asks for a code or password", "interview only by chat"],
    )


def test_a_name_that_only_ends_like_the_employer():
    """nottessera.example is not on tessera.example"""
    assert triage(message(sender="nottessera.example")) == ("verify first", ["sender not on the employer's domain"])


def test_public_webmail_and_no_interview():
    """an offer without an interview, from webmail, is checked first"""
    assert triage(message(sender="gmail.com", interview="none")) == (
        "verify first",
        ["no interview", "sender not on the employer's domain"],
    )


def test_a_role_that_is_not_listed():
    """a role missing from the careers site is checked first"""
    assert triage(message(on_careers_site=False)) == ("verify first", ["role not on the careers site"])


def test_every_flag_in_order():
    """the flags come in the order of the prompt"""
    assert triage(
        message(
            sender="tessera-careers.example",
            asks_money=True,
            asks_secret=True,
            interview="none",
            on_careers_site=False,
        )
    ) == (
        "scam",
        [
            "asks for money",
            "asks for a code or password",
            "no interview",
            "sender not on the employer's domain",
            "role not on the careers site",
        ],
    )
A hint

Build the list of flags first, one if per flag in the order of the prompt, then decide the verdict from it. For the domain, lower-case both names and accept the sender when it equals the employer's domain or ends with a dot followed by it: "." + employer. Without the dot, nottessera.example would pass.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then check yourself, weakest idea first:

Practice that adapts to you

Practise this lesson

12 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 According to the US Federal Trade Commission, when may an honest employer ask you to pay to get a job?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: Never; anyone who asks you to pay to get a job is a scammer

    Honest employers, the federal government included, never ask you to pay to get a job, so a request for money is enough to recognise a scam.

  2. Question 2 of 12 For a week an app has paid you small sums for rating product photos. Now it says you must deposit money before you can withdraw your balance of 18,000. What is happening?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: A task scam; the balance is not real, and a deposit will not come back

    Small early payouts build trust, then the deposits begin. Someone telling you to pay to get the money you have supposedly earned is, in the FTC's words, a sure sign of a scam.

  3. Question 3 of 12 Which of these is not one of the FTC's four signs of a scam?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: The message comes on a weekday

    The four signs are pretending to be an organisation you know, a problem or a prize, pressure to act at once, and a demand to pay in a specific way.

  4. Question 4 of 12 A new "employer" posts you a cheque for 50,000, asks you to keep 5,000 as your first pay and send 45,000 to a supplier. The cheque shows as deposited. Why is it a scam?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: The cheque will bounce, and you may owe the bank the 45,000 you sent on

    In a fake-cheque scam the cheque looks deposited at first and bounces later. The money you sent on is gone, and the bank may ask you to repay the whole amount.

  5. Question 5 of 12 A message from tessera.example, the employer's real domain, offers you a role you interviewed for by video, and asks you to confirm by tonight or lose the offer. What do you do?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: Verify through the careers site and the recruiter you met, and ask for the written offer before deciding

    Signs add up. A real domain and a real interview point to a genuine offer, a hard deadline points the other way, so you verify with the recruiter and ask for the written offer you can read.

  6. Question 6 of 12 Which part of "Tessera HR <hr@tessera-careers.example>" do you compare with the employer's domain?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: The domain after the @, tessera-careers.example

    Take the address, not the name, and compare its domain with the one on the employer's careers site.

  7. Question 7 of 12 A message shows the employer's real address, meghna@tessera.example, and your mail service recorded DMARC fail. What is the most likely explanation?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: The From address was forged; the domain's owner did not send the message

    That is the program's message 5: the right domain and a failed check, so the verdict is a forged sender.

  8. Question 8 of 12 Priya writes from priya@hirewell.example, a recruiting agency, about a role at Tessera Labs, and the message passed DMARC. What is the right next step?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: Ask Tessera Labs, through a contact on its careers site, whether HireWell recruits for it

    The program's last rule, for another domain, is to ask the employer. A pass from the agency's domain is fine; what you still need is the employer's word that the agency recruits for it.

  9. Question 9 of 12 Where do you take the phone number or address you use to check an offer with the employer?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: From the employer's own careers site

    Verify through a channel the scammer cannot control: the role and the contact details on the employer's own careers site.

  10. Question 10 of 12 After a video interview, a caller says they are from the employer's onboarding team and asks for the one-time password your bank just sent you, "to link your salary account". What do you do?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: Refuse and end the call; an OTP is never shared with anyone

    OTPs, passwords and card numbers are never shared during hiring, whatever came before the request.

  11. Question 11 of 12 What do you keep once a job message turns out to be a scam?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: The messages, the numbers, the payment receipts and the transaction IDs

    Stop replying and keep the evidence: the reports that follow all ask for it.

  12. Question 12 of 12 You paid nothing to a fake recruiter who used Wipro's name. Besides reporting the message, what else helps, according to Wipro's page?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Telling Wipro through its recruitment helpdesk, the contact its own fraud page gives

    The employer whose name was used can warn other candidates and act against the fakes. Wipro gives its recruitment helpdesk for this, and asks anyone who paid to file a police complaint as well.

Interview questions

A recruiter screen, an HR round or a security interview may ask these:

  1. Fresher to mid · Warm-up

    You receive an offer by email for a role you never interviewed for. What do you check before you reply?

    An offer without an interview is already a warning sign, so I check three things before I reply. Money first: if it asks me to pay anything, a fee, a deposit or a purchase, it is a scam and I stop. Then the sender: I compare the domain after the @ with the one on the employer's careers site, accepting only that domain or a subdomain of it, and I treat public webmail and look-alike domains as scams even when DMARC passed. Then the role: I look for it on the careers site and contact the employer through details from that site, never from the message. Until all three check out I share no documents, and I never share a one-time password, a password or a card number. If it is a scam, I report it and tell the employer whose name was used.

  2. Fresher to senior · Warm-up

    An email that claims to come from an employer passes SPF, DKIM and DMARC. Does that prove the employer sent it?

    No. DMARC checks that the domain in the From address authorised the message, so a pass proves that this domain sent it, not that the domain belongs to the employer. A scammer who registers a look-alike such as tessera-careers.example can set up SPF, DKIM and DMARC for it and pass all three; the DMARC standard itself says it does not deal with visually similar domain names. So I first compare the domain with the one on the employer's careers site, exactly or as a subdomain, and only then read the result. I read the Authentication-Results line that my own mail service added, because a sender can write a forged one into the message. A fail on the employer's real domain is the opposite case: the address was forged.

Key takeaways

  • Any request for money to get or keep a job marks a scam, whether it is called a fee, a deposit or a top-up; honest employers never charge candidates.
  • Contact out of the blue, a chat-only interview, an offer without an interview, pay for easy tasks, a cheque to pass on and pressure to decide at once are the other signs, and a message that shows any of them is verified first.
  • Check the sender’s domain against the one on the employer’s careers site, exactly or as a subdomain; a DMARC pass proves only that a domain sent the message, so a look-alike that passes is still a scam.
  • Verify the role on the careers site and contact the employer through its own details; never share codes, passwords or card numbers, and share documents only after a verified written offer.
  • If you paid, call your bank or payment app at once and report the fraud, at 1930 or cybercrime.gov.in in India and ReportFraud.ftc.gov in the US; then report the message and tell the employer whose name was used.

Terms in this lesson

DMARC
Domain-based Message Authentication, Reporting and Conformance: an email standard by which a receiving server checks that the domain in a message's From address authorised it. A pass says nothing about who owns that domain.
edit distance
The fewest single-letter changes, insertions or deletions that turn one word into another; tesera is one edit away from tessera.
fake-cheque scam
A scam in which a new employer or buyer sends you a cheque and asks you to send part of the money on; the cheque bounces later, and you may owe your bank the full amount.
look-alike domain
A domain name made to resemble an employer's own, with a word added or a letter changed, such as tessera-careers.example or tesera.example for tessera.example.
task scam
A job scam that pays small sums for simple online tasks, such as liking videos or rating products, then asks for deposits before you may go on or withdraw the balance shown, which is never paid out.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.