OFAC sanctions screening for AI agents: how name matching works

OFAC sanctions screening for AI agents with real scores: which lists we index, how fuzzy matching scores Kim Jong Un and Prigozhin, and its limits.

An agent that onboards a supplier, pays an invoice or drafts a contract with a counterparty will sooner or later need to ask one question: is this name on a US sanctions list? The question looks like a lookup. In practice it is a fuzzy-matching problem, because names arrive transliterated, reordered, abbreviated and misspelled. This article explains which OFAC data 402check indexes, how its us_sanctions tool scores a name, with real scores from our code, and what a score does not tell you.

Status first: us_sanctions is listed as coming soon. Until it launches, GET /v1/us/sanctions answers 503 with {"error":"coming_soon"} and nothing is charged. Everything below describes the code that will serve it; every score in this article comes from running that code against our OFAC test fixture.

What OFAC publishes

The US Treasury's Office of Foreign Assets Control publishes its lists through the Sanctions List Service. The best-known one, the Specially Designated Nationals and Blocked Persons list (SDN), "contains the names of individuals, entities and groups designated by OFAC", and it also covers blocked vessels and aircraft. The non-SDN lists are published as a separate Consolidated set of data files.

Our index uses four files from the service's export endpoint, in the legacy CSV format: SDN.CSV and its aliases ALT.CSV, and CONS_PRIM.CSV with CONS_ALT.CSV for the Consolidated list. When we wrote the parser, on 2026-10-27, SDN.CSV had 19,471 rows and CONS_PRIM.CSV had 486. The two lists reuse entry numbers (93 numbers appeared in both), so an entry is identified by its list and its number, never by the number alone.

Each indexed entry keeps its name, all its aliases (a.k.a., f.k.a. and n.k.a.), its type (individual, entity, vessel, aircraft) and its sanctions programs. Remarks such as dates of birth are not returned.

Normalize before comparing

Before any comparison, both the query and every listed name go through the same normalization: accents are stripped, everything is lowercased, apostrophes and periods are removed, any other run of non-alphanumeric characters becomes a space, and the words are sorted alphabetically. The OFAC record KIM, Jong Un becomes jong kim un; the query Kim Jong Eun becomes eun jong kim; Nicolás Maduro Moros and the record MADURO MOROS, Nicolas both become maduro moros nicolas and score 1. Sorting removes the "surname, given names" ordering of the list from the problem.

The score

The score is the highest of three measures, each between 0 and 1:

  1. Jaro–Winkler on the whole sorted string. Good for typos and small spelling differences, and it rewards a shared prefix. It is only used when every word of one side has a close counterpart on the other side (a word-level Jaro–Winkler of at least 0.8); otherwise it would reward strings that merely start alike.
  2. Jaccard on the sets of words. Shared words divided by all distinct words, so Osama bin Laden against the alias BIN LADEN, Osama is exactly 1.
  3. Symmetric Monge–Elkan. For each word, take its best Jaro–Winkler against the other side's words and average those; do this in both directions and keep the lower average. A word with no counterpart on either side pulls the score down.

That is the whole function:

TypeScript src/tools/sanctions/similarity.ts
/**
 * max(jaroWinkler on the sorted-token strings — only when one side's tokens are all covered by the other's —,
 * Jaccard on token sets, symmetric Monge-Elkan = min of the mean best-token jaroWinkler in each direction).
 */
export function nameScore(a: NameForm, b: NameForm): number {
  const { aBest, bBest } = alignTokens(a.tokens, b.tokens);
  const covered = covers(a.tokens, aBest) || covers(b.tokens, bBest);
  const fullString = covered ? jaroWinkler(a.normalized, b.normalized) : 0;
  const mongeElkan = Math.min(mean(aBest), mean(bBest));
  return Math.max(fullString, jaccard(a.tokenSet, b.tokenSet), mongeElkan);
}

Each listed name and each alias is scored separately; an entry's score is its best one, and the response says which name or alias it matchedOn. The default threshold is minScore=0.8.

Real scores

These are the outputs of nameScore, rounded to three decimals as the API returns them, for names in our fixture:

Query OFAC record Score Why
Yevgeniy Prigozhin PRIGOZHIN, Yevgeniy Viktorovich 0.881 Both query words are in the record; whole-string Jaro–Winkler
Evgeni Prigozhin PRIGOZHIN, Yevgeniy Viktorovich 0.807 evgeni vs yevgeniy is 0.917 at word level; the unmatched patronymic pulls Monge–Elkan down
Kim Jong Eun KIM, Jong Un 0.843 Two exact words; eun has no close counterpart, so Monge–Elkan carries it
Prigozhin PRIGOZHIN, Yevgeniy Viktorovich 0.86 The surname sorts first, so the whole-string prefix bonus applies
Kim KIM, Jong Un 0.333 One word of three; the surname sorts in the middle
John Smith NUMBI, John 0.733 Shares only a given name; stays under the 0.8 default

Three things follow from this table.

Transliteration variants clear the default threshold, but not by much. Evgeni Prigozhin passes at 0.807. A different romanization with one more changed letter could fall below 0.8, which is why minScore is a parameter.

A surname alone is unpredictable. Prigozhin scores 0.86 because, after sorting, it is the first word of the record and Jaro–Winkler rewards the shared prefix. Kim scores 0.333 because kim sorts into the middle of jong kim un. If your agent only has a surname, lower minScore (0.6 is a reasonable start) and expect more candidates to review.

The coverage rule is what keeps unrelated people out. On the raw sorted strings, john smith against john numbi has a Jaro–Winkler of 0.88, high enough to be reported as a hit. Because smith has no counterpart in the record, the whole-string measure is skipped, and the pair scores 0.733 on Monge–Elkan instead.

What the response gives the agent

A call returns up to 20 matches sorted by score, each with the entry (id, name, aliases, type, programs, list), the score and matchedOn. truncated: true says more entries cleared the threshold than were returned. You can restrict results to individuals or entities with type; entries whose type could not be read are kept under both filters rather than silently dropped. A query has at most 12 words.

Every response also carries indexUpdatedAt. The index is rebuilt by the Worker's cron every day at 03:00 UTC. If the index is more than 48 hours old, results come back with stale: true and a warning. Past 7 days the tool refuses to answer with 503 index_stale, uncharged, because screening against a week-old list would be misleading. A rebuild that would shrink the index by more than 10% compared with the previous one is rejected, and the previous index stays in place.

What a score is not

OFAC runs its own free Sanctions List Search, which also "uses approximate string matching" with an adjustable threshold, and it notes that "OFAC does not provide recommendations with regard to the appropriateness of any specific confidence rating." The same applies to our minScore: the threshold is a trade-off between missed matches and noise that you have to choose for your use case.

A score is also not a determination. OFAC's FAQ on valid matches asks screeners to "evaluate the quality of the potential match" by comparing the other information on the record, such as dates of birth and addresses, before deciding anything. Our response gives the entry id and list so a person, or a later step in your agent, can do that against the full OFAC record.

The limits, plainly:

  • US lists only: SDN and Consolidated. No EU, UK or UN lists.
  • Names only. No ownership structures, no addresses, no dates of birth in the response.
  • A lookup aid, not legal or compliance advice.

Try it

Once the tool is live, a screen is one GET at $0.01, and the first 10 calls a day per IP are free. Today it returns 503 coming_soon, uncharged:

Shell
curl "https://402check.com/v1/us/sanctions?name=Yevgeniy+Prigozhin"

# a surname only: lower the threshold and review the candidates
curl "https://402check.com/v1/us/sanctions?name=Prigozhin&type=individual&minScore=0.6"

Parameters, the full example response and the MCP call are on the us_sanctions docs page.