<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Haruo</title>
    <description>The latest articles on DEV Community by Haruo (@haruodev).</description>
    <link>https://dev.to/haruodev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4049958%2Fe6d836af-a2df-42d1-a0cf-c7b38e0ff9cc.png</url>
      <title>DEV Community: Haruo</title>
      <link>https://dev.to/haruodev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/haruodev"/>
    <language>en</language>
    <item>
      <title>Find out whether a PDF was edited, with one curl and no signup</title>
      <dc:creator>Haruo</dc:creator>
      <pubDate>Wed, 02 Sep 2026 12:39:26 +0000</pubDate>
      <link>https://dev.to/haruodev/find-out-whether-a-pdf-was-edited-with-one-curl-and-no-signup-pbe</link>
      <guid>https://dev.to/haruodev/find-out-whether-a-pdf-was-edited-with-one-curl-and-no-signup-pbe</guid>
      <description>&lt;p&gt;The thing that took me longest to internalise about this file format: &lt;strong&gt;a PDF is not a picture of a document.&lt;/strong&gt; It is an append-only container with a linked list of revisions in it, and it keeps receipts about its own history whether or not anyone wanted it to.&lt;/p&gt;

&lt;p&gt;Which means "was this edited after it was made?" is often answerable from the bytes, deterministically, with no model involved. Here is how to ask, in one command, without an account.&lt;/p&gt;

&lt;h2&gt;
  
  
  The call
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://tamperlens.com/api/v1/inspect &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;@statement.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;Authorization&lt;/code&gt; header. Anonymous callers get the full report, rate limited to 10 documents per hour per IP. The cap is 10 MB per document, the bytes are parsed in memory and discarded when the response is written, and there is no human review step.&lt;/p&gt;

&lt;p&gt;A raw body works too, if your HTTP client makes multipart expensive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://tamperlens.com/api/v1/inspect &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/pdf"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @statement.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The format is decided by sniffing the bytes, never from &lt;code&gt;Content-Type&lt;/code&gt; or the filename. A phone that renamed a JPEG to &lt;code&gt;.pdf&lt;/code&gt; is analysed as the JPEG it is. Word, Excel and PowerPoint files and JPEG, PNG, WebP, HEIC and AVIF images go to the same endpoint and come back in the same report shape, with a &lt;code&gt;mediaType&lt;/code&gt; discriminator.&lt;/p&gt;

&lt;h2&gt;
  
  
  What comes back
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"insp_2f1e9c04-..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"engineVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.37.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"riskScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"riskBand"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"low"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"signalCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"revisions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"signals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"producer-fingerprint"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"low"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PDF editing tool in the production chain (iLovePDF)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The document's metadata names iLovePDF in its production chain. ..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"evidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"iLovePDF"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"origin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pdf-editor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"producer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iLovePDF"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"creator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iText 7.2.5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"revisions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"document"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"pages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"producer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iLovePDF"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"creator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iText 7.2.5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"creationDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-01-04T10:02:00.000Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"modDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-01-06T18:41:00.000Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"encrypted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"signed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"revisions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sizeBytes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;184320&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"disclaimer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tamperlens reports risk signals, not authenticity verdicts. ..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to know before you write any code against it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;riskBand&lt;/code&gt; is &lt;code&gt;low&lt;/code&gt; under 30, &lt;code&gt;elevated&lt;/code&gt; 30 to 69, &lt;code&gt;high&lt;/code&gt; at 70 and above&lt;/strong&gt;, and the &lt;code&gt;high&lt;/code&gt; band is narrow on purpose: it is reserved for reports containing at least one high-severity signal, so a pile-up of weak findings is clamped at 69 and can never reach it. That is a contract, not an accident of the arithmetic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;signals[].id&lt;/code&gt; is a stable string.&lt;/strong&gt; Branch on ids rather than on the score when you know your own domain, and you will: if your flow legitimately involves customers signing documents, you want &lt;code&gt;incremental-updates&lt;/code&gt; weighted down (a signature &lt;em&gt;is&lt;/em&gt; an incremental update) and &lt;code&gt;signature-coverage&lt;/code&gt; weighted up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;signals[].evidence&lt;/code&gt; is the part a human reviewer needs.&lt;/strong&gt; It is small, it contains no document content, and it is per-family and additive, so read keys defensively. Store it next to the decision it produced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a positive before you trust a negative
&lt;/h2&gt;

&lt;p&gt;This is the part I would actually do first, and it takes about four minutes.&lt;/p&gt;

&lt;p&gt;Take a PDF whose history you personally know: something you generated, or a statement you downloaded straight from a bank. Inspect it. Then run the same file through any free browser-based PDF editor, change one number, download it, and inspect &lt;em&gt;that&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;You can watch the mechanism before the API even answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'%%EOF'&lt;/span&gt; statement.pdf
2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A PDF ends with &lt;code&gt;%%EOF&lt;/code&gt;. Two of them is the format's own record that something was appended after the original save. Every appended cross-reference section's trailer carries &lt;code&gt;/Prev&lt;/code&gt;, the byte offset of the previous one, which is a backwards linked list you can walk to the original document.&lt;/p&gt;

&lt;p&gt;The count alone is weak evidence, and you should treat it that way: signing a document is an incremental update, so is filling in a form field, and a linearised "fast web view" file legitimately has two markers from a single save. The strong version is what the appended revision &lt;em&gt;replaced&lt;/em&gt;. A later revision that replaces an object which already existed, and that carries page or content-stream data, has no reading compatible with "the issuer generated this and nobody touched it."&lt;/p&gt;

&lt;p&gt;Watching which signals light up on a file whose history you know is worth more than any vendor's page, including mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Send your decision rule with the request
&lt;/h2&gt;

&lt;p&gt;You will otherwise reimplement &lt;code&gt;if (riskScore &amp;gt;= 70)&lt;/code&gt; on your side, slightly differently from everyone else, and invisibly to whoever you ask about it later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://tamperlens.com/api/v1/inspect &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'X-Tamperlens-Policy: {"review":30,"reject":70,"rejectOn":["redaction-exposure"]}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/pdf"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @statement.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response gains one block and nothing else changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"riskScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"thresholds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"review"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"signal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"triggeredBy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"redaction-exposure"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four behaviours worth knowing, because each is a decision:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;defaults are the engine's own band boundaries&lt;/strong&gt;, so an unconfigured policy agrees with &lt;code&gt;riskBand&lt;/code&gt; instead of quietly disagreeing with it.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;named family beats a threshold&lt;/strong&gt;, and &lt;code&gt;rejectOn&lt;/code&gt; beats &lt;code&gt;reviewOn&lt;/code&gt;. Naming a family means it matters regardless of what the arithmetic came to, and a threshold that could veto that would make the rule advisory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unknown signal ids are accepted and never match.&lt;/strong&gt; A family we rename must not be able to take your pipeline down.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;malformed policy is a 422, never a silent fallback to our defaults.&lt;/strong&gt; A typo that leaves you believing a rule is enforced when it is not is the one failure mode this must not have.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Errors worth writing code for
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Body&lt;/th&gt;
&lt;th&gt;Do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;413&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"error":"file_too_large","maxMb":10}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;check size client-side first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;422&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"error":"analysis_timeout","timeoutMs":15000}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;do not retry, the input is the problem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;429&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"error":"rate_limited","retryAfterSeconds":60}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;you called too fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;429&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"error":"quota_exceeded","quota":25,"used":25}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;queue, do not drop, the counter resets monthly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;503&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"error":"busy","retryAfterSeconds":5}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;retry with backoff, &lt;code&gt;Retry-After&lt;/code&gt; is set&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;rate_limited&lt;/code&gt; and &lt;code&gt;quota_exceeded&lt;/code&gt; are deliberately distinct: a quota is the plan you bought, a rate limit is the pace you called at.&lt;/p&gt;

&lt;p&gt;And the one that catches people out: &lt;strong&gt;a file the parser cannot make sense of is not an error.&lt;/strong&gt; Unparseable and hostile input degrades into a &lt;code&gt;200&lt;/code&gt; carrying a &lt;code&gt;structure-warnings&lt;/code&gt; signal. A garbage PDF is a finding, not a failure, and treating it as a 5xx would mean throwing away the most interesting document of the day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two properties you can build tests on
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It is deterministic.&lt;/strong&gt; Identical bytes produce an identical report apart from &lt;code&gt;id&lt;/code&gt;. Pin your fixtures, snapshot the reports, and regression-test the vendor: that is an explicitly supported use of the free tier. Pin &lt;code&gt;engineVersion&lt;/code&gt; while you are there, because a bump means the scoring weights may have moved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing is stored.&lt;/strong&gt; No document is written to disk, no document content is logged, no third party is in the analysis path, and no outbound call happens while inspecting. What persists is a usage row that holds no document.&lt;/p&gt;

&lt;h2&gt;
  
  
  If the caller is an agent
&lt;/h2&gt;

&lt;p&gt;An agent that opens an attachment has already read whatever the attachment says, and a PDF is a convenient place to put a sentence addressed to the model rather than to the person. So the useful order is &lt;strong&gt;inspect, then read&lt;/strong&gt;: one call before the bytes reach your model's context, branching on the injection families exactly as you would on any other signal.&lt;/p&gt;

&lt;p&gt;The payload does not have to travel with the finding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s1"&gt;'https://tamperlens.com/api/v1/inspect?redact=payload'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @resume.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every attacker-authored string is elided and the signal gains &lt;code&gt;evidence.payloadRedacted: true&lt;/code&gt;, so "nothing was found" stays distinguishable from "you were not given it". The score, the band and every count are identical.&lt;/p&gt;

&lt;p&gt;There is an MCP server over the same endpoints, &lt;code&gt;npx tamperlens-mcp&lt;/code&gt;, which redacts unconditionally, because an MCP tool result &lt;em&gt;is&lt;/em&gt; a model's context.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a clean report does not mean
&lt;/h2&gt;

&lt;p&gt;The honest limit, and I would rather write it than have you find it.&lt;/p&gt;

&lt;p&gt;The engine reports what is true of the bytes. A structural signal can be defeated: flattening a page to a raster image throws away almost everything there is to read, printing the file from a browser rewrites its whole account of itself, and stripping the metadata and then rewriting the file removes both the metadata and the history in one go. I published the whole matrix of what defeats each of my own signals, with the cells I got wrong, because the alternative is a reader who sees a clean report and hears "this document is genuine".&lt;/p&gt;

&lt;p&gt;Those are different claims. A clean report means the bytes are clean.&lt;/p&gt;

&lt;p&gt;So the report is a set of signals and never a verdict. "This file contains two revisions, a later revision replaced a page content stream, and the Info dictionary names an online PDF editor that the XMP packet does not" is a statement about bytes that a human can act on. "This document is fraudulent" is a statement about a person, and a parser does not get to make it.&lt;/p&gt;




&lt;p&gt;The one-screen quickstart is at &lt;a href="https://tamperlens.com/api?utm_campaign=devto-quickstart" rel="noopener noreferrer"&gt;tamperlens.com/api&lt;/a&gt;, the full schema and every error code at &lt;code&gt;/api-reference&lt;/code&gt;, and the machine-readable spec with a try-it console at &lt;code&gt;/docs&lt;/code&gt;. If you would rather drag a file into a page than write a request, the &lt;a href="https://tamperlens.com/?utm_campaign=devto-quickstart" rel="noopener noreferrer"&gt;free checker&lt;/a&gt; returns the same report, with the raw evidence under every finding and no account involved.&lt;/p&gt;

&lt;p&gt;If a signal fires on something you know to be completely innocent, that is the comment I want most. Those are the interesting ones.&lt;/p&gt;

</description>
      <category>api</category>
      <category>tutorial</category>
      <category>security</category>
      <category>pdf</category>
    </item>
    <item>
      <title>A published invariant is a test you have not written yet</title>
      <dc:creator>Haruo</dc:creator>
      <pubDate>Tue, 01 Sep 2026 18:36:56 +0000</pubDate>
      <link>https://dev.to/haruodev/a-published-invariant-is-a-test-you-have-not-written-yet-4i3</link>
      <guid>https://dev.to/haruodev/a-published-invariant-is-a-test-you-have-not-written-yet-4i3</guid>
      <description>&lt;p&gt;Three places in our product said the same thing about a report:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One report contains &lt;strong&gt;at most one signal per family&lt;/strong&gt;. A family either fires once, with all its findings collected into a single &lt;code&gt;evidence&lt;/code&gt; object, or it does not appear at all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The public field guide says it. &lt;code&gt;docs/ARCHITECTURE.md&lt;/code&gt; restates it. And &lt;code&gt;engine/message.ts&lt;/code&gt; relies on it, mapping a family id to a single explanation on the strength of it.&lt;/p&gt;

&lt;p&gt;Nothing enforced it. Three families had never obeyed it, on our own checked-in fixtures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;office-doctype.docx            office-structure-anomalies [medium] [low] [info]
office-macro-template.docx     office-active-content      [high] [medium] [medium]
office-injection-markers.docx  office-hidden-content      [medium] [low]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three consumers had been built on a promise that nothing was keeping. This is what it cost, what the fix looked like, and the one violation we decided to publish instead of repair.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things went wrong at once, and none was visible from inside the family
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The score inflated.&lt;/strong&gt; &lt;code&gt;scoreSignals&lt;/code&gt; decays the second and later signals of a given severity, on the assumption that they are &lt;em&gt;different families corroborating each other&lt;/em&gt;. That is a good assumption and it is the reason the score is not just a sum. A family emitting three findings therefore collected three independent contributions for one finding. &lt;code&gt;office-macro-template.docx&lt;/code&gt; scored &lt;strong&gt;100&lt;/strong&gt; where the same evidence now scores &lt;strong&gt;70&lt;/strong&gt;: &lt;code&gt;high&lt;/code&gt; 70 plus &lt;code&gt;medium&lt;/code&gt; 25 plus &lt;code&gt;medium&lt;/code&gt; 25 is 120, clamped to 100. Two more fixtures moved with it, 100 to 95 and 35 to 30.&lt;/p&gt;

&lt;p&gt;Worth noting what did &lt;strong&gt;not&lt;/strong&gt; move: no fixture changes risk band. The fold always keeps the strongest finding, and the band turns on the presence of a severity rather than on how many findings carried it, so &lt;code&gt;high&lt;/code&gt; stays &lt;code&gt;high&lt;/code&gt;. That is the difference between a scoring bug and a detection bug, and it is why nobody noticed for as long as they did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One number meant two things.&lt;/strong&gt; &lt;code&gt;summary.signalCount&lt;/code&gt; is what the report headline prints. It counted findings on Office files and families everywhere else. A single API field with two definitions, decided by which engine ran.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The UI repeated itself.&lt;/strong&gt; The report card keys a signal's brief text and its "What this signal means" link &lt;strong&gt;by family&lt;/strong&gt;. So three signals of one family rendered three identical cards with three identical links.&lt;/p&gt;

&lt;p&gt;None of those three failures is visible from inside the family that caused them. They live in the scorer, in the summary, and in the renderer, which is the general shape of an invariant violation: the family that breaks it is never the code that pays.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fold, which is not a summary
&lt;/h2&gt;

&lt;p&gt;The fix is a shared helper, &lt;code&gt;src/engine/fold.ts&lt;/code&gt;, generalised from code one PDF family had used since engine 1.14.0.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;strongest&lt;/strong&gt; finding keeps its title and its whole paragraph, so a reader gets the full explanation of the thing that mattered most.&lt;/li&gt;
&lt;li&gt;Every other finding appends &lt;strong&gt;one sentence&lt;/strong&gt; through &lt;code&gt;notes&lt;/code&gt;, a mechanism two families already used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No evidence is dropped.&lt;/strong&gt; Every check's evidence object is merged whole.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;evidence.checks&lt;/code&gt; names which checks fired, so a consumer can branch without reading prose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is the one I would carry to another codebase. A fold that summarises is a fold that loses data, and the loss shows up months later in a support thread. A fold that merges and then &lt;em&gt;names what it merged&lt;/em&gt; costs one array and turns "did the doctype check fire?" into a lookup instead of a string match against a title that copy review is free to rewrite.&lt;/p&gt;

&lt;p&gt;Making the merge lossless took two evidence-key renames: two different checks had both been writing &lt;code&gt;parts&lt;/code&gt;, so merging them would have silently dropped one. If you are folding evidence objects, the collisions are the work.&lt;/p&gt;

&lt;p&gt;One family did &lt;strong&gt;not&lt;/strong&gt; move onto the helper, deliberately. &lt;code&gt;embedded-image-anomalies&lt;/code&gt; keeps its own inline copy, because the strings in its &lt;code&gt;evidence.checks&lt;/code&gt; are evidence key names rather than check names. That is a shipped shape in a public API, and changing it would have been a breaking change with no defect behind it. Consistency inside the engine is worth less than a stable contract outside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The duplicate that no amount of folding could prevent
&lt;/h2&gt;

&lt;p&gt;Folding inside a family fixes a family that emits three. It does nothing about a &lt;strong&gt;registry&lt;/strong&gt; that appends a fourth.&lt;/p&gt;

&lt;p&gt;Ours did: a coverage-disclosure family was in the registry's loop &lt;em&gt;and&lt;/em&gt; was appended again by the loop's failure path, for any family that threw. So it ran twice on exactly the documents that had gone wrong.&lt;/p&gt;

&lt;p&gt;It left the loop. It now runs last, once, and is handed the list of families that threw. Which is what the comment beside it always claimed it did.&lt;/p&gt;

&lt;p&gt;The always-on coverage disclosure also repeats &lt;strong&gt;in full&lt;/strong&gt; when it becomes a note rather than being shortened, and that was a choice: an abbreviated statement of what was not looked at is not a disclosure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exception we published instead of fixing
&lt;/h2&gt;

&lt;p&gt;One family still emits two signals, knowingly.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;redaction-exposure&lt;/code&gt; reports a filled box drawn over text (&lt;code&gt;high&lt;/code&gt;, conclusive) and a picture drawn over text (&lt;code&gt;info&lt;/code&gt;) as separate findings. Merging them was the earlier behaviour and it was a &lt;em&gt;measured&lt;/em&gt; bug: one document announced four exposed text runs at high severity when three of them were a letterhead. The two findings also carry the same evidence keys at different scopes, so the fold above would silently drop one set of numbers.&lt;/p&gt;

&lt;p&gt;Resolving it properly means either nesting that family's evidence or changing the published sentence, and both are decisions about what the product promises rather than refactors. So the exception is now stated in all three of the places the invariant is stated: on the public page, in the architecture doc, and in the test that enforces it, each carrying the argument for why.&lt;/p&gt;

&lt;p&gt;The alternative was leaving a sentence on a public page that the engine disagrees with, which is the failure mode this whole exercise exists to fix. &lt;strong&gt;An exception you state is documentation. An exception you leave implicit is a lie with a timestamp on it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The test, and the two things it does that a normal test would not
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;test/engine/one-signal-per-family.test.ts&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It runs all three engines over every fixture in the repository&lt;/strong&gt;, enumerated from the directory rather than from a list. The defect was in the Office engine, and the test is not a case in the Office suite, because the property is about all three and the one that broke it was the one nobody was looking at. Enumerating the directory is the other half of that: a fixture added tomorrow is checked tomorrow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It collects every violation before asserting&lt;/strong&gt;, rather than stopping at the first. A test that fails on the first violation makes you fix and re-run once per instance, which is how you end up believing you are done.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each engine also carries a floor ("fewer than 60 PDF fixtures means the sweep stopped seeing them"), because a glob that silently matches nothing is a green test.&lt;/p&gt;

&lt;p&gt;It found a fourth violating fixture the review had not, which is how the &lt;code&gt;redaction-exposure&lt;/code&gt; exception came to light in the first place. It also pins that &lt;code&gt;summary.signalCount&lt;/code&gt; counts families rather than findings, on all three engines, because the invariant and the count are the same promise stated twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other half: a published number needs a derivation, not a reviewer
&lt;/h2&gt;

&lt;p&gt;Two counting bugs turned up next to this one, and they rhyme.&lt;/p&gt;

&lt;p&gt;A coverage test had been counting Office families &lt;strong&gt;with a regex over the source of a private array&lt;/strong&gt;. It read the right number on the day it was written. It would have read 7 the moment the disclosure family left that array, which is precisely what this refactor did. It imports an exported constant now. A published number that a refactor can quietly change is the failure this repository keeps rediscovering.&lt;/p&gt;

&lt;p&gt;And a family count was baked into a &lt;strong&gt;PNG&lt;/strong&gt;. The social card for the flagship guide said "All ten signal families" while the guide's own heading said eighteen, and that card is the &lt;code&gt;og:image&lt;/code&gt; on ten pages. The sweep that checks every count on the site could not see it twice over: the string lives in a build script outside the sweep's roots, and what ships is an image, which no text lint can read. So a test now reads the card generator's source and derives the expected number from the engine's own registry.&lt;/p&gt;

&lt;p&gt;The rule under all three: &lt;strong&gt;if you publish a number or an invariant, you owe it a derivation.&lt;/strong&gt; Not a reviewer, not a style note, not a comment. A reviewer does not count families across ninety-six pages, and a comment cannot fail CI.&lt;/p&gt;




&lt;p&gt;The invariant, the eighteen families it applies to and the one stated exception are in the &lt;a href="https://tamperlens.com/pdf-fraud-signals?utm_campaign=devto-invariant" rel="noopener noreferrer"&gt;fraud signals field guide&lt;/a&gt;. If you want to see the report shape the invariant is about, the &lt;a href="https://tamperlens.com/?utm_campaign=devto-invariant" rel="noopener noreferrer"&gt;free checker&lt;/a&gt; takes a PDF and gives back the same JSON the API returns, with the evidence under every finding. No account, and nothing is stored.&lt;/p&gt;

&lt;p&gt;If you have shipped a guard that turned out to be enforced at the wrong granularity, I would like to read about it. That is the class of bug I now assume I have more of.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>typescript</category>
      <category>architecture</category>
      <category>node</category>
    </item>
    <item>
      <title>The half of Claude's watermark you can actually check</title>
      <dc:creator>Haruo</dc:creator>
      <pubDate>Sat, 15 Aug 2026 17:29:47 +0000</pubDate>
      <link>https://dev.to/haruodev/the-half-of-claudes-watermark-you-can-actually-check-1482</link>
      <guid>https://dev.to/haruodev/the-half-of-claudes-watermark-you-can-actually-check-1482</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/sylwia-lask/the-end-of-undetectable-ai-text-claudes-new-watermark-explained-45g2"&gt;Sylwia Lask's post on Claude's watermark&lt;/a&gt; is the best-natured thing I have read on this topic in a while, and it is right about the two things people keep getting wrong: the EU AI Act asks for machine-detectability, not a ban, and a watermark hit tells you a model was &lt;em&gt;involved&lt;/em&gt;, not that it &lt;em&gt;wrote&lt;/em&gt; anything.&lt;/p&gt;

&lt;p&gt;I want to add to it rather than argue with it, because that announcement has two halves and the comment threads only ever reach one of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Half one is text.&lt;/strong&gt; A keyed statistical mark, spread across token choices. It is the half everybody is discussing and, as I will get to, the half you personally cannot test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Half two is files.&lt;/strong&gt; Anthropic began attaching C2PA content credentials to generated images on 11 August. C2PA is an open standard with an open reference tool, so this half is checkable by anyone, today, with no key and no vendor cooperation.&lt;/p&gt;

&lt;p&gt;I build a PDF signal engine, so the second half is my problem. On 14 August I put a signed image through every PDF generator on this laptop to find out what a downstream reader can still see. That is the measured part of this post. First, though, two things about the text half, because both keep coming up in that thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism has a specification you can read
&lt;/h2&gt;

&lt;p&gt;The post reasons from first principles to statistical token watermarking, which is the right guess, and it is careful to label it as a guess. Since then the guess has been confirmed, in that post's own comment thread: on 14 August the author notes that Anthropic updated its documentation and Claude is indeed using SynthID. &lt;a href="https://support.claude.com/en/articles/16266773-how-claude-marks-ai-generated-content" rel="noopener noreferrer"&gt;Anthropic's page&lt;/a&gt; puts it as a version of the &lt;strong&gt;SynthID-Text&lt;/strong&gt; approach published by Google DeepMind.&lt;/p&gt;

&lt;p&gt;That fact deserves to be out of a comment thread and in front of people, because it changes the epistemic status of the whole conversation. There is one scheme here, not two, and it has a specification: &lt;a href="https://www.nature.com/articles/s41586-024-08025-4" rel="noopener noreferrer"&gt;Nature 634, 818-823&lt;/a&gt;. You do not have to guess at the mechanism, you can read it. As the model writes, it hashes the last few tokens with a secret key and uses that seed to bias which of several equally good next words it emits. In unmarked text those tokens arrive at chance rate, so counting them gives a z-score and an actual p-value. That is the real advantage over a classifier: a calibrated false-positive rate instead of a vibe.&lt;/p&gt;

&lt;p&gt;It also means the published attacks apply directly, which is the part most coverage still misses while calling the mechanism undisclosed. "Undisclosed proprietary magic" and "a published scheme with known attacks" support very different decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  It is not invisible characters, and stripping them does nothing
&lt;/h2&gt;

&lt;p&gt;The post gets this right and it is worth repeating, because stripping invisible characters is the first thing everyone reaches for: the mark is &lt;strong&gt;not&lt;/strong&gt; zero-width Unicode. Not &lt;code&gt;U+200B&lt;/code&gt;, not &lt;code&gt;U+FEFF&lt;/code&gt;, not the tag block.&lt;/p&gt;

&lt;p&gt;I know those characters well, because our text extraction strips them before anything else reads the text, for prompt-injection reasons that have nothing to do with watermarking. Which gives me the two-sided version of the myth:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Removing every invisible character from a paragraph does &lt;strong&gt;nothing&lt;/strong&gt; to a statistical watermark. The signal is in &lt;em&gt;which words were chosen&lt;/em&gt;, not in anything you can see or delete.&lt;/li&gt;
&lt;li&gt;Finding invisible characters in a document proves &lt;strong&gt;nothing&lt;/strong&gt; about Claude either. They arrive from copy-paste, from CMSs, from PDF extraction. The presence of a carrier is not evidence of a payload.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What does remove the mark is documented and cheap. Re-tokenisation, which is as simple as inserting a character between every word and deleting it again, destroys the n-gram contexts every hash-based scheme seeds from. OpenAI names that attack in its own writing on why it never shipped text watermarking. Paraphrase removes it outright below roughly 800 tokens. And &lt;a href="https://arxiv.org/abs/2402.19361" rel="noopener noreferrer"&gt;Jovanović, Staab and Vechev, ICML 2024&lt;/a&gt; showed that for under $50 an attacker can both spoof and scrub state-of-the-art schemes, with over 80% average success. Spoofing is the direction nobody plans for: forging a mark onto human text is the attack that hurts a person.&lt;/p&gt;

&lt;p&gt;Then there is the gate that ends the practical conversation. &lt;strong&gt;The scheme is symmetric.&lt;/strong&gt; Without the secret key you cannot reconstruct the seeds, and without the seeds there is nothing to count. For a third party holding a suspicious paragraph, the answer today is not "run the test and see". The test cannot be run at all.&lt;/p&gt;

&lt;p&gt;The fallback everyone reaches for instead, a commercial AI-text classifier, is worse than its reputation: 61.22% of human-written TOEFL essays flagged as AI by seven detectors in &lt;a href="https://arxiv.org/abs/2304.02819" rel="noopener noreferrer"&gt;Liang et al.&lt;/a&gt;, 78.3% of granted patent claims flagged by a leading open detector, and around two dozen universities that switched their detector off and none that switched it back on. I keep the receipts for that in a guide, &lt;a href="https://tamperlens.com/detect-ai-generated-text" rel="noopener noreferrer"&gt;how to detect AI-generated text&lt;/a&gt;, which mostly exists to explain why the honest answer to the question is "you cannot, and here is what you can ask instead".&lt;/p&gt;

&lt;p&gt;That is the whole text half. A sound scheme, a key you do not have, and known attacks. Which is why I spend my time on the other half.&lt;/p&gt;

&lt;h2&gt;
  
  
  The half you can check: what happened to nine PDFs
&lt;/h2&gt;

&lt;p&gt;C2PA is not an AI detector. It is a signed manifest inside the file with a hash binding the claim to the bytes, so it is a first-person statement you can verify. My question is narrower than "is this AI": it is &lt;strong&gt;does what a file declares about itself still validate&lt;/strong&gt;, and does the declaration survive the trip to wherever a reviewer opens it.&lt;/p&gt;

&lt;p&gt;Fraud review sees PDFs. Anthropic signs &lt;code&gt;.svg&lt;/code&gt;, &lt;code&gt;.png&lt;/code&gt; and &lt;code&gt;.jpg&lt;/code&gt;. So the question is what happens when a signed image goes into a document, and as far as I can tell nobody had measured it. I built 30 files and 9 PDFs on one machine on 14 August, with local tools only, using &lt;code&gt;c2patool&lt;/code&gt; 0.27.15's development certificate for signing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A signed JPEG survives PDF embedding, byte for byte.&lt;/strong&gt; Six of six paths: &lt;code&gt;img2pdf&lt;/code&gt;, Chrome print-to-PDF at natural size and scaled to 47%, Ghostscript two ways, and &lt;code&gt;qpdf --linearize&lt;/code&gt;. The DCT stream comes out of the PDF identical to the one that went in, and re-validates. I had assumed Chrome's print pipeline re-encoded and it does not: the scaling is applied by the content stream's transformation matrix instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PNG and SVG survive nothing.&lt;/strong&gt; Zero of two and zero of one. A PDF has no PNG container, so the pixels are re-emitted as a Flate raster and the &lt;code&gt;caBX&lt;/code&gt; chunk, which is a PNG chunk, has nowhere to go. Chrome draws an SVG as vector operators and produces no image object at all. Of the three formats Anthropic signs, exactly one carries its credential into a document, and it is the one document fraud actually uses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And here is the result I did not expect.&lt;/strong&gt; &lt;code&gt;c2patool&lt;/code&gt; returns &lt;code&gt;Error: No claim found&lt;/code&gt; on all nine PDFs, &lt;strong&gt;including the six whose embedded JPEG carries a fully valid manifest.&lt;/strong&gt; The official reader looks for a PDF-level manifest. The credential is sitting one indirection away, inside an image stream, intact and unread.&lt;/p&gt;

&lt;p&gt;So the honest summary of the file half is: the credential survives the trip, and the standard tool for reading credentials will tell you the document is unsigned. If you want to find it you have to walk the object graph, pull the image streams out and validate those.&lt;/p&gt;

&lt;p&gt;Two more numbers from the same run, both of which cut against the optimistic reading:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Five of five ordinary re-saves destroy the credential.&lt;/strong&gt; A JPEG quality change, a PNG re-save, a format conversion, a resize, and a screenshot of the image. Every marker gone, not degraded. The screenshot of the signed PNG came out the same size as the unsigned original, because a credential is not part of what a renderer renders. &lt;strong&gt;Absence is the overwhelming case and it means nothing at all.&lt;/strong&gt; Anyone building a rule on "no manifest, therefore suspicious" is building a rule that fires on the entire honest internet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Six of six tampers detected&lt;/strong&gt;, with two distinct codes that a report must not conflate: &lt;code&gt;assertion.dataHash.mismatch&lt;/code&gt; (the picture changed under a good claim) and &lt;code&gt;claimSignature.mismatch&lt;/code&gt; (the claim bytes themselves changed).&lt;/p&gt;

&lt;h2&gt;
  
  
  What this cannot tell you
&lt;/h2&gt;

&lt;p&gt;The limits belong up front, not in a footnote, so here are mine.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every validation above is against a development certificate.&lt;/strong&gt; No file signed by a real Anthropic chain exists in my corpus. "Claude" describes what generated the image, never what validated the signature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three survival paths are unmeasured, not zero&lt;/strong&gt;: LibreOffice export, Word to PDF, and a WhatsApp round-trip. Not installed, or a manual phone step. If you have any of the three, that is a genuinely useful thing to run and post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Four of the five surfaces Anthropic lists are untested.&lt;/strong&gt; I checked one, Claude Code, where five of five generated images carried no manifest. The caveat that makes that honest: Claude Code has no image-generation model. It writes an SVG and drives your own C2PA-unaware local tools to rasterize it, so no signing step exists in that path. That is not "Anthropic failed to ship". It is what the support page's own hedge, &lt;em&gt;"some platforms or features may not support certain marking types"&lt;/em&gt;, costs a downstream verifier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;None of this says how often signed files arrive.&lt;/strong&gt; Our standing baseline over real documents is 0 of 4,902 embedded images. This is early infrastructure, not a present-day signal.&lt;/li&gt;
&lt;li&gt;One machine, one day, one Chrome build. A print pipeline with downsampling enabled, or Acrobat's "reduce file size", would each be a row I did not run.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would actually do with a suspicious document
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Do not start with the prose.&lt;/strong&gt; The text test is key-gated and the classifiers have the false-positive rates above. Notice you are holding a file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask the file.&lt;/strong&gt; A PDF is an append-only container that keeps receipts about its own history: revisions appended after creation, the producing toolchain, an Info dictionary and an XMP packet that stopped agreeing, whether a signature still covers what it signed, and printed totals that do or do not add up. None of that is about the writing, and all of it is deterministic. That is the &lt;a href="https://tamperlens.com/detect-ai-generated-pdf" rel="noopener noreferrer"&gt;AI-generated PDF&lt;/a&gt; guide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check provenance where it exists, and read absence as nothing.&lt;/strong&gt; For images the &lt;a href="https://tamperlens.com/detect-ai-generated-image" rel="noopener noreferrer"&gt;AI-generated image&lt;/a&gt; guide covers what a valid manifest proves, what it never proves, and why absence is uninformative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never let one signal become a verdict.&lt;/strong&gt; Signals about bytes are defensible. "Our system detected fraud" is the sentence that ends up in a complaint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full write-up of the nine PDFs, with the survival matrix and the reproduction steps, is on my site: &lt;a href="https://tamperlens.com/blog/claude-c2pa-signature-inside-pdf?utm_campaign=devto-c2pa" rel="noopener noreferrer"&gt;A C2PA signature survives a PDF, and c2patool can't see it&lt;/a&gt;. There is a free checker on &lt;a href="https://tamperlens.com/?utm_campaign=devto-c2pa" rel="noopener noreferrer"&gt;tamperlens.com&lt;/a&gt; if you want to drop a document in and see what its own bytes say. Nothing is stored.&lt;/p&gt;

&lt;p&gt;If you run the LibreOffice, Word or WhatsApp path, I would genuinely like the result, including if it contradicts mine. And if you disagree with the framing that the text half is unusable today, the comments are the right place for it: that is the half I would most like to be wrong about.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>pdf</category>
      <category>watermark</category>
    </item>
    <item>
      <title>The black box in your PDF is a shape, not a delete key</title>
      <dc:creator>Haruo</dc:creator>
      <pubDate>Mon, 03 Aug 2026 15:44:04 +0000</pubDate>
      <link>https://dev.to/haruodev/the-black-box-in-your-pdf-is-a-shape-not-a-delete-key-1nc3</link>
      <guid>https://dev.to/haruodev/the-black-box-in-your-pdf-is-a-shape-not-a-delete-key-1nc3</guid>
      <description>&lt;p&gt;There are two ways to black out a name in a PDF.&lt;/p&gt;

&lt;p&gt;The first deletes the text and then draws a black rectangle where it used to be.&lt;br&gt;
The second just draws the black rectangle.&lt;/p&gt;

&lt;p&gt;On screen they are indistinguishable. In the file they are entirely different&lt;br&gt;
documents, and in the second one every character of the name is still there —&lt;br&gt;
selectable, copyable, and extractable by any PDF library in about one line of&lt;br&gt;
code.&lt;/p&gt;

&lt;p&gt;This mistake keeps reaching production in court filings, FOIA releases and&lt;br&gt;
regulatory submissions, from organisations that employ lawyers and document&lt;br&gt;
teams. It survives not because people are careless but because &lt;strong&gt;there is no&lt;br&gt;
feedback&lt;/strong&gt;: the person doing the redacting sees a black box either way, and&lt;br&gt;
nothing tells them which one they made until somebody else selects the text.&lt;/p&gt;
&lt;h2&gt;
  
  
  A PDF page is a program
&lt;/h2&gt;

&lt;p&gt;The reason the two operations look the same is worth understanding, because it&lt;br&gt;
is also the reason you can tell them apart.&lt;/p&gt;

&lt;p&gt;A page's content stream is a sequence of operators executed in order onto a blank&lt;br&gt;
canvas. A very small one looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BT /F1 12 Tf 76 660 Td (Dana Whitfield) Tj ET
0 0 0 rg
74 656 120 16 re f
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading it out: begin text, select font &lt;code&gt;F1&lt;/code&gt; at 12pt, move to (76, 660), show the&lt;br&gt;
string &lt;code&gt;Dana Whitfield&lt;/code&gt;, end text. Then set the non-stroking colour to black&lt;br&gt;
(&lt;code&gt;rg&lt;/code&gt;), build a rectangle at (74, 656) 120 wide and 16 high (&lt;code&gt;re&lt;/code&gt;), and fill it&lt;br&gt;
(&lt;code&gt;f&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;There is no z-index here, and no concept of one object being "above" another.&lt;br&gt;
There is only order. Later paints over earlier. The rectangle covers the name for&lt;br&gt;
the same reason a second coat of paint covers the first.&lt;/p&gt;

&lt;p&gt;Now swap the two halves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 0 0 rg
74 656 120 16 re f
BT /F1 12 Tf 76 660 Td (Dana Whitfield) Tj ET
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same objects, same coordinates, opposite order — and now the name is drawn &lt;em&gt;on&lt;br&gt;
top of&lt;/em&gt; the black box and is perfectly legible. Which is exactly what a table's&lt;br&gt;
shaded header row is: a filled rectangle, painted first, with text on it.&lt;/p&gt;

&lt;p&gt;That single fact is the whole of what follows.&lt;/p&gt;
&lt;h2&gt;
  
  
  Check it yourself in one line
&lt;/h2&gt;

&lt;p&gt;If you have a PDF with a black box in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pdftotext &lt;span class="nt"&gt;-layout&lt;/span&gt; suspect.pdf - | less
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the "redacted" words appear in that output, they were never removed. That is&lt;br&gt;
the entire test, and it is worth running on anything you are about to send.&lt;/p&gt;

&lt;p&gt;To look at the operators rather than the text, decompress the streams first —&lt;br&gt;
content streams are Flate-compressed, so &lt;code&gt;strings&lt;/code&gt; and &lt;code&gt;grep&lt;/code&gt; see nothing useful&lt;br&gt;
without this step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qpdf &lt;span class="nt"&gt;--qdf&lt;/span&gt; &lt;span class="nt"&gt;--object-streams&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;disable suspect.pdf decompressed.pdf
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;--text&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;' (re|f|Tj|TJ)$'&lt;/span&gt; decompressed.pdf | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-40&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you can read the page as the program it is, and see for yourself whether the&lt;br&gt;
&lt;code&gt;Tj&lt;/code&gt; comes before the &lt;code&gt;re f&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why "is there a dark rectangle near text" is the wrong check
&lt;/h2&gt;

&lt;p&gt;The naive detector — find dark filled rectangles, find text underneath, report —&lt;br&gt;
fires on an enormous number of completely honest documents. Every table with a&lt;br&gt;
shaded header. Every highlighted paragraph. Every coloured callout box.&lt;/p&gt;

&lt;p&gt;Paint order separates them cleanly, and it does so &lt;em&gt;by construction&lt;/em&gt; rather than&lt;br&gt;
by tuning: a background that was painted after its text would have hidden that&lt;br&gt;
text, so it would not be a background. If you can read the text, the shape came&lt;br&gt;
first. If a shape came second, it is hiding something.&lt;/p&gt;

&lt;p&gt;Three more guards matter in practice, and each one exists because the version&lt;br&gt;
without it produced false positives:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transparency.&lt;/strong&gt; A highlighter is a filled rectangle painted over text, and it&lt;br&gt;
hides nothing. The graphics state carries a non-stroking alpha (&lt;code&gt;ca&lt;/code&gt;, set through&lt;br&gt;
a named &lt;code&gt;ExtGState&lt;/code&gt;), so anything meaningfully translucent is ignored.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Size.&lt;/strong&gt; A shape covering more than about 40% of the page is a watermark, a&lt;br&gt;
stamp or a page background. Nobody redacts a name by covering half the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invisible text.&lt;/strong&gt; Rendering mode 3 (&lt;code&gt;3 Tr&lt;/code&gt;) draws nothing. That is the OCR&lt;br&gt;
layer underneath a scanned page — text that is &lt;em&gt;designed&lt;/em&gt; to be invisible on&lt;br&gt;
screen and extractable by machine. Reporting it as a failed redaction would fire&lt;br&gt;
on every scanned document ever produced.&lt;/p&gt;
&lt;h2&gt;
  
  
  Getting the words back out is a separate problem
&lt;/h2&gt;

&lt;p&gt;Finding that a run of text sits under a box tells you there is a leak. Saying&lt;br&gt;
&lt;em&gt;what&lt;/em&gt; leaked needs one more step, because the bytes inside a &lt;code&gt;Tj&lt;/code&gt; string are&lt;br&gt;
character &lt;strong&gt;codes&lt;/strong&gt;, not characters, and what they mean depends on the font.&lt;/p&gt;

&lt;p&gt;A font can carry a &lt;code&gt;/ToUnicode&lt;/code&gt; CMap that maps them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 begincodespacerange
&amp;lt;00&amp;gt; &amp;lt;FF&amp;gt;
endcodespacerange
5 beginbfchar
&amp;lt;01&amp;gt; &amp;lt;0041&amp;gt;
&amp;lt;02&amp;gt; &amp;lt;0044&amp;gt;
&amp;lt;03&amp;gt; &amp;lt;004D&amp;gt;
&amp;lt;04&amp;gt; &amp;lt;0049&amp;gt;
&amp;lt;05&amp;gt; &amp;lt;004E&amp;gt;
endbfchar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that, the content-stream bytes &lt;code&gt;\001\002\003\004\005&lt;/code&gt; — which are not text&lt;br&gt;
in any useful sense — resolve to &lt;code&gt;ADMIN&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Fonts without a &lt;code&gt;/ToUnicode&lt;/code&gt; map are common, and there the honest answer is "the&lt;br&gt;
coverage is real, the words could not be decoded here" rather than a guess.&lt;br&gt;
Simple fonts usually turn out to be WinAnsi-encoded, where the byte &lt;em&gt;is&lt;/em&gt; the&lt;br&gt;
character, which covers most of the practical cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that are not redaction
&lt;/h2&gt;

&lt;p&gt;All of these leave every character in the file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Drawing a filled rectangle over the text.&lt;/li&gt;
&lt;li&gt;Using a black highlighter annotation.&lt;/li&gt;
&lt;li&gt;Setting the text colour to match the background.&lt;/li&gt;
&lt;li&gt;Placing an image over the area.&lt;/li&gt;
&lt;li&gt;Adding a &lt;code&gt;/Redact&lt;/code&gt; &lt;strong&gt;annotation&lt;/strong&gt; and not applying it. This one is the cruellest,
because it is the &lt;em&gt;right tool used incompletely&lt;/em&gt; — the annotation is a mark
requesting redaction, the content only leaves when the tool applies the marks,
and most viewers draw the pending marks as solid black boxes. The half-finished
state looks exactly like the finished one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What does work: your tool's redaction feature, followed by actually applying it.&lt;br&gt;
Or exporting the page to a flattened image, which removes the text layer along&lt;br&gt;
with your searchable text.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this kind of check cannot see
&lt;/h2&gt;

&lt;p&gt;Worth being explicit, because a checker that implies more coverage than it has is&lt;br&gt;
worse than none:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Form XObjects.&lt;/strong&gt; Reusable content blocks are their own little content
streams. A covering box inside one is invisible unless you recurse into them,
so any count is a floor rather than a total.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Already-flattened pages.&lt;/strong&gt; No text layer, nothing to find — which also means
the redaction worked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything outside the page content.&lt;/strong&gt; A name taken off the visible page can
still be sitting in the document metadata, an attachment, a form field, or an
earlier revision of the file. Different problem, different checks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you would rather not do it by hand
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://tamperlens.com/redaction-check" rel="noopener noreferrer"&gt;Tamperlens&lt;/a&gt; because I got tired of running the &lt;code&gt;qpdf&lt;/code&gt;/&lt;code&gt;pdftotext&lt;/code&gt; dance on documents people sent me and wondering whether I had missed one. Drop a PDF in and it walks each page's content stream tracking the transformation matrix, fill colour, alpha and paint order, computes boxes for the text runs, and reports the runs that ended up under&lt;br&gt;
something opaque — with the recovered text, because if a checker can read it so can anyone who received the document. Nothing is stored: files are parsed in memory and discarded with the response.&lt;/p&gt;

&lt;p&gt;There is a fictional sample on the page that opens automatically, so you can see&lt;br&gt;
the output before deciding whether to trust it with anything of your own. It&lt;br&gt;
contains a shaded table band with text on it as well as two real covers, so you&lt;br&gt;
can watch the discrimination work in the same report.&lt;/p&gt;

&lt;p&gt;The most useful four minutes you can spend with it: take any document, draw a&lt;br&gt;
black rectangle over a line in whatever editor you have, export it, and check&lt;br&gt;
that. Watching it recover the words from a file whose history you personally know&lt;br&gt;
is worth more than this post.&lt;/p&gt;

</description>
      <category>security</category>
      <category>pdf</category>
      <category>privacy</category>
      <category>showdev</category>
    </item>
    <item>
      <title>What a forged bank statement looks like at the byte level</title>
      <dc:creator>Haruo</dc:creator>
      <pubDate>Mon, 27 Jul 2026 17:49:36 +0000</pubDate>
      <link>https://dev.to/haruodev/what-a-forged-bank-statement-looks-like-at-the-byte-level-16ji</link>
      <guid>https://dev.to/haruodev/what-a-forged-bank-statement-looks-like-at-the-byte-level-16ji</guid>
      <description>&lt;p&gt;I spent a chunk of my career in KYC/AML and document verification, mostly looking at things people had uploaded hoping nobody would look too closely — so this is the file format I have the most opinions about and the least affection for.&lt;/p&gt;

&lt;p&gt;Here is the thing that took me embarrassingly long to internalise: &lt;strong&gt;a PDF is not a picture of a document.&lt;/strong&gt; It is an append-only container with a linked list of revisions in it, and it keeps receipts about its own history whether or not anyone wanted it to. Most people editing a PDF have no idea that the thing they edited is still in the file.&lt;/p&gt;

&lt;p&gt;Let me walk a constructed example. Imagine a bank statement, generated server-side, two pages, one credit line that says the account holder received rather less than they would like a lender to believe. They download it, drop it into a free browser-based PDF editor, white out the number, type a bigger one, hit download. Two minutes of work.&lt;/p&gt;

&lt;p&gt;Here is what that leaves behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Count the ends of the file
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'%%EOF'&lt;/span&gt; statement.pdf
2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A PDF ends with &lt;code&gt;%%EOF&lt;/code&gt;. Ours contains two of them, and the second one is not a typo.&lt;/p&gt;

&lt;p&gt;PDF supports &lt;strong&gt;incremental update&lt;/strong&gt;: a conforming writer, instead of rewriting the file, may append new objects, append a new cross-reference section covering only what changed, append a new trailer, and append a new &lt;code&gt;startxref&lt;/code&gt; / &lt;code&gt;%%EOF&lt;/code&gt;. The original bytes are left exactly where they were. A document saved three times can physically contain all three states, and you can render any of them by truncating the file at the right offset.&lt;/p&gt;

&lt;p&gt;This is a feature, not a bug — it is how digital signatures manage to preserve the bytes they signed. Which also means the count alone is weak evidence. Signing a document is an incremental update. So is filling in a form field, or adding an annotation. Linearised ("fast web view") files legitimately have two &lt;code&gt;%%EOF&lt;/code&gt; markers from a single save, because the first-page cross-reference section sits near the front of the file with its own trailer.&lt;/p&gt;

&lt;p&gt;So: interesting, not conclusive. Keep going.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-abo&lt;/span&gt; &lt;span class="s1"&gt;'startxref'&lt;/span&gt; statement.pdf
118201:startxref
183640:startxref
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Walk the /Prev chain
&lt;/h2&gt;

&lt;p&gt;Every appended cross-reference section's trailer carries &lt;code&gt;/Prev&lt;/code&gt;: the byte offset of the previous section. That is a backwards linked list. Enter it at the &lt;em&gt;last&lt;/em&gt; &lt;code&gt;startxref&lt;/code&gt; in the file and walk until a trailer has no &lt;code&gt;/Prev&lt;/code&gt;; that terminal section is the original document.&lt;/p&gt;

&lt;p&gt;Our tail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;trailer
&amp;lt;&amp;lt; /Size 41 /Root 1 0 R /Info 9 0 R
   /Prev 118201 /ID [&amp;lt;8f2c9a1b...&amp;gt; &amp;lt;41de77c0...&amp;gt;] &amp;gt;&amp;gt;
startxref
183640
%%EOF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now read the appended cross-reference section that &lt;code&gt;183640&lt;/code&gt; points at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;xref
0 1
0000000000 65535 f
12 1
0000119004 00000 n
trailer
&amp;lt;&amp;lt; ... /Prev 118201 ... &amp;gt;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It lists exactly one live object: &lt;strong&gt;12&lt;/strong&gt;. And object 12 already existed in revision 1.&lt;/p&gt;

&lt;p&gt;That is the whole finding. Object resolution in PDF always uses the newest section that mentions an object number, so whatever object 12 is now, it has replaced what object 12 used to be. Check what kind of object it is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;mutool show statement.pdf 12
&amp;lt;&amp;lt; /Length 2841 /Filter /FlateDecode &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A content stream. On a page. That means what the document &lt;em&gt;renders&lt;/em&gt; changed after it was generated — and revision 1 is still sitting in the file at offset 118201, so I can extract both and diff them.&lt;/p&gt;

&lt;p&gt;This is the distinction I would put on a wall if I were running a document-review team: "the file has two revisions" is a medium-strength observation with a dozen innocent explanations. "A later revision replaces an object that already existed and that carries page or content-stream data" is a strong one, because there is no version of that sentence which is compatible with "the bank generated this and nobody touched it."&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The /ID pair, which the spec maintains for you
&lt;/h2&gt;

&lt;p&gt;Look again at that trailer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/ID [&amp;lt;8f2c9a1b...&amp;gt; &amp;lt;41de77c0...&amp;gt;]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two byte strings. The specification gives them different jobs: the first is a permanent identifier established when the document is created and must never change; the second is rewritten every time the file is written again. Two different values is the format's own record that this file was saved after it was created.&lt;/p&gt;

&lt;p&gt;The original trailer, at offset 118201, has &lt;code&gt;/ID [&amp;lt;8f2c9a1b...&amp;gt; &amp;lt;8f2c9a1b...&amp;gt;]&lt;/code&gt; — matching, as a freshly created file should. Watching element two drift while element one holds still across revisions is a satisfying little confirmation that you have read the chain correctly.&lt;/p&gt;

&lt;p&gt;Its weakness matters as much as its strength: a tool that rewrites the &lt;em&gt;whole&lt;/em&gt; file can set both elements to the same fresh value and erase the trace entirely. So divergence is informative; identity proves nothing. Almost every PDF signal is one-directional like this, which is why any honest tool in this space sells signals rather than verdicts.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Two metadata stores that stopped agreeing
&lt;/h2&gt;

&lt;p&gt;Modern PDFs describe themselves twice: the trailer's &lt;code&gt;/Info&lt;/code&gt; dictionary, and an XMP packet hanging off the document catalog's &lt;code&gt;/Metadata&lt;/code&gt; key. XMP is RDF/XML and is conventionally stored &lt;em&gt;unfiltered&lt;/em&gt;, which is why &lt;code&gt;strings&lt;/code&gt; finds it when it finds nothing else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;$ strings statement.pdf | grep -A6 'x:xmpmeta'
&lt;span class="nt"&gt;&amp;lt;pdf:Producer&amp;gt;&lt;/span&gt;iText 7.2.5&lt;span class="nt"&gt;&amp;lt;/pdf:Producer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;xmp:CreateDate&amp;gt;&lt;/span&gt;2026-01-04T10:02:00+01:00&lt;span class="nt"&gt;&amp;lt;/xmp:CreateDate&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;xmp:ModifyDate&amp;gt;&lt;/span&gt;2026-01-04T10:02:00+01:00&lt;span class="nt"&gt;&amp;lt;/xmp:ModifyDate&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the Info dictionary in the newest revision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/Producer (iLovePDF)
/Creator (iText 7.2.5)
/CreationDate (D:20260104100200+01'00')
/ModDate (D:20260106184100+01'00')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in the format keeps those two in sync. Whether they agree is purely a property of the tools that touched the file, and plenty of editors rewrite the dictionary while leaving the XML alone because setting a dictionary value is much less work than rewriting XML.&lt;/p&gt;

&lt;p&gt;Note precisely what happened here. The XMP packet still says the file was made by iText and never modified. The Info dictionary says iLovePDF and carries a &lt;code&gt;/ModDate&lt;/code&gt; two days later. &lt;strong&gt;The divergence is the only reason the editor is visible at all.&lt;/strong&gt; If you were only reading XMP — which is the modern store, and the one a lot of tooling prefers — you would see a clean file.&lt;/p&gt;

&lt;p&gt;Multi-stage publishing pipelines produce plain divergence legitimately all the time, so a bare mismatch is a question rather than an answer. The narrow, strong version is this one: the two stores disagree about the tool, and only one of them names something whose purpose is editing.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The tell that survives metadata cleanup
&lt;/h2&gt;

&lt;p&gt;Suppose our forger reads a post like this one and strips the metadata. Then this is still there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;strings statement.pdf | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'/BaseFont *[^ /]*'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
/BaseFont /ABCDEF+Helvetica
/BaseFont /QWERTY+Helvetica
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Embedded fonts are usually subsetted — only the glyphs the document actually uses — and the name gets a six-uppercase-letter prefix and a plus sign. The letters are arbitrary. What matters is that a generator writing a document in one pass collects every glyph it needs for a typeface into &lt;strong&gt;one&lt;/strong&gt; subset with &lt;strong&gt;one&lt;/strong&gt; tag.&lt;/p&gt;

&lt;p&gt;Two tags for one typeface mean glyphs of that typeface were embedded on two separate occasions. That is the trace left when text is added to, or replaced in, an existing PDF by a different tool — and because it lives in the page resources rather than in metadata, it survives metadata stripping &lt;em&gt;and&lt;/em&gt; it survives a whole-file rewrite that flattens the revision chain.&lt;/p&gt;

&lt;p&gt;It is also the signal I have seen misread most often, so: merging two PDFs that both use Helvetica produces exactly this pattern, innocently. Someone combining two monthly statements into one upload will trip it. Never read it alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. What a ByteRange gap actually proves
&lt;/h2&gt;

&lt;p&gt;If the statement is digitally signed — routine in parts of the EU and LATAM — there is one more check, and it is the strongest structural finding available in the format because it is &lt;em&gt;arithmetic&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A signature dictionary's &lt;code&gt;/ByteRange&lt;/code&gt; is an array of offset/length pairs naming the bytes that were hashed, normally covering everything except the hole where the signature itself sits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/ByteRange [0 8420 42840 15680]

covered through 42840 + 15680 = 58520
file length                   = 61204
                                ------
bytes outside the signature    =  2684
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those 2,684 bytes were appended after signing and are not protected by that signature. No certificate needed, no trust store, no crypto: you compare two integers. And it holds &lt;em&gt;regardless of whether the signature validates&lt;/em&gt;, which is the part that catches people out. A viewer can correctly report a valid signature while displaying content the signature never covered — that is what "signed, with subsequent changes" means, and users click straight past it.&lt;/p&gt;

&lt;p&gt;The honest caveat, because this one gets oversold: bytes outside the range are not automatically illegitimate. Multi-signature workflows produce them by construction, and PDF has a whole mechanism (DocMDP permissions) for declaring which subsequent changes the signer allowed. Reporting a coverage gap is not the same as deciding the change was forbidden.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable part
&lt;/h2&gt;

&lt;p&gt;None of these signals is robust on its own, and I would distrust anyone who tells you otherwise.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Metadata cleanup is one line of code and kills signals 3 and 4.&lt;/li&gt;
&lt;li&gt;A whole-file rewrite kills the revision chain and can erase the &lt;code&gt;/ID&lt;/code&gt; divergence — but destroys any signature and leaves conspicuously fresh, coherent metadata.&lt;/li&gt;
&lt;li&gt;Flattening the page to a raster image kills the font tell — and produces a document whose own shape (a full-page scan where a machine-generated statement should be vector text) is the finding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The evasions are close to mutually exclusive. That is the actual reason a &lt;em&gt;set&lt;/em&gt; of independent signals is worth having: not because any one of them is hard to defeat, but because defeating one tends to light up another.&lt;/p&gt;

&lt;p&gt;It is also why I think the only defensible framing is &lt;strong&gt;signals, not verdicts&lt;/strong&gt;. "This file contains two revisions, a later revision replaced a page content stream, and the Info dictionary names an online PDF editor that the XMP packet does not" is specific, true of the bytes, and something a human can act on. "Our system detected fraud" is neither of those things, and it is the sentence that ends up in a complaint.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to skip the hex editor
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://tamperlens.com/" rel="noopener noreferrer"&gt;Tamperlens&lt;/a&gt; because nothing self-serve existed between "read the bytes yourself" and "request a demo". It is a raw-byte parser — deliberately not built on a high-level PDF library, since those normalise away exactly the evidence described here — that runs eleven signal families and hands back JSON.&lt;/p&gt;

&lt;p&gt;There is a free checker on the front page: drag a PDF in, get the report, nothing is stored. The most useful thing you can do with it takes about four minutes — put a statement you know is genuine through it, then run the same file through a free online PDF editor and put &lt;em&gt;that&lt;/em&gt; through. Watching which signals light up on a file whose history you personally know is worth more than any blog post, including this one.&lt;/p&gt;

&lt;p&gt;Every signal family, with its benign causes documented at the same length as its malicious ones, is in the &lt;a href="https://tamperlens.com/pdf-fraud-signals.html" rel="noopener noreferrer"&gt;field guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Happy to argue about any of this in the comments — particularly if you have a case where one of these signals fired on something entirely innocent, because those are the interesting ones.&lt;/p&gt;

</description>
      <category>security</category>
      <category>pdf</category>
      <category>fintech</category>
      <category>node</category>
    </item>
  </channel>
</rss>
