Back in the day, an ordinary HTTP served webpage would sometimes get ads inserted by the network operator/ISP, because HTTP traffic is unencrypted and therefore the recipient cannot prove or disprove that the ad was not there in the first place. One would open http://www.example.com in a browser, the network operator would fetch the request, but before serving the page it would get slightly modified. Of course, this also means some external actor could just rewrite the entire page, censor it etc. In order to prevent that, HTTPS is forced on in most browsers today. However, that only gets you so far.
Almost every major corporation has an enterprise root certificate authority, double-speak for a man-in-the-middle (MITM) certificate, injected into your browser before you can access any webpage, to log everything for security reasons of course. This certificate is turned on if you are using a company VPN, usually without any kind of notification or warning. With it even online-banking secrets are openly exposed for the company admins to peek into. On a personal note, I recommend turning off this behavior for your own devices. If you are on Firefox/LibreWolf open about:config and set security.certerrors.mitm.auto_enable_enterprise_roots to false.
Another attack-vector are scripts on third-party hosts, for instance unsafe CDNs. Instead of hijacking the content of the webpage directly, the dependencies are used. A script, loaded from a remote CDN, is exchanged with a new version that injects code into the page or changes its DOM tree.
Then there are the state-actors. In several countries telecom providers for instance will send you an SMS on sim-card activation that contains a root certificate disguised as a link for you to install to improve Wifi or some other nonsense. Once installed, all encryption is basically circumvented, similarly to the MITM certificate in corporations.
And finally, local injection through plugins or malicious code installed on the machine of the browser, or the browser itself being the product of an ad-corporation, can of course modify the page after fully encrypted and safe delivery, because the endpoint is compromised. A common use-case is actually ad-blocking or customization, but I am not worried about those.
In any case, HTTPS is circumvented entirely and the end-device can no longer guarantee the content is genuine or safe.
My page does not serve or pull any third-party resources, does not use remote fonts, does not have any Javascript and relies on plain HTML5 and CSS, without featuring elements that are targets of ad-blockers. This gets rid of some of the above scenarios, but not all.
For this reason, some time ago in a small weekend exercise, I started to sign my entire webpage to shield it from external modifications. The project is related to this RFC and in a similar vein to this post, however with the goal to have fixed signatures for all webpages and the RSS feed of this page.
I use a static page generator to build the entire page from Markdown documents, but this should work for any other system that produces static content as well.
Signing a webpage
The idea is simple: After all HTML files have been generated, iterate over every file and sign it with gpg into an armored ascii output, then run it through base64. The result is added to the end of each HTML file as a XML comment.
My page is generated with Jekyll. I end up with a directory of the baked page that contains among other things a bunch of .html files and a file index.xml, which is the RSS feed of this page.
Before continuing, there is one issue to tackle: Signatures, under normal circumstances, are not stable. This means, signing the same document twice will yield two different signatures. This is due to the fact that most algorithms such as RSA inject a timestamp and a random nonce. While we can fixate the input data and timestamp, RSA based signatures cannot be made deterministic. This is by design to prevent attacks to reconstruct the original private key from many collected signatures.
If we want to use the signature to also communicate whether or not a document has been changed - similar to a hash - we need deterministic signatures. EdDSA can produce safe deterministic signatures. To generate a key to sign documents, first run:
$ gpg --quick-gen-key "Your Name <you@example.com>" ed25519 sign
This will yield a key that we will use for signing the page. If you have multiple keys, determine the KEY_ID of the key by running:
$ gpg --list-secret-keys
[keyboxd]
---------
pub ed25519 YYYY-MM-DD [SC]
KEY_ID
uid [ultimate] Your Name <you@example.com>
Generating a signature for any string input can be done using:
$ gpg --detach-sign --armor --local-user=KEY_ID --faked-system-time=TS
This command will generate a signature for a KEY_ID separate form the signed document, as pure text with the signature set to a specific timestamp TS.
To get a compact representation of the signature we will further encode it using base64. We can test the entire pipeline and some fixed integer value TS. TS must also be bigger than the creation date of the key:
$ curl -sS -L https://www.tobias-franke.eu | gpg --detach-sign --armor --faked-system-time=TS | base64
gpg: WARNING: running with faked system time: YYYY-MM-DD HH:MM:SS
LS0tLS1CRUdJTiBQR1AgU0lHTkFUVVJFLS0tLS...Cg==
Re-running the command will yield the same signature, so we have a successful prototype on our hands.
HTML
For HTML files we will use the entire page as input for the signature. To find the right timestamp for a page, a HTML parser will look on my page for <meta property="og:published_time" content="DATE_XMLSCHEMA">. This may require a different solution on your page (perhaps the last modified timestamp of the generated file could serve as well).
We can use a very simple way to attach the base64 encoded signature by simply adding an XML comment as the last line of the page.
<html>
...
</html>
<!-- SIGNATURE -->
To verify the page, the signature comment must first be decoded from its base64 representation, then stripped from the page and the checked against the remaining document.
Atom or RSS Feeds
For an RSS feed the issue is a bit more tricky, but in the end more elegant.
Of course the entire feed XML can be signed, but quite often many feeds only contain the last N articles of a page. When signing the entire document as one block, the signature would change every time a new article is added. For the HTML files so far we have a signature per article or per page, and ideally it would work analogously for the feed.
We can also sign each article - in RSS feeds called an <item> with a <description> that contains the article's text - individually. Items feature an optional element called <guid> which acts as a unique identifier for the feed item. The standard describes it like this:
<guid>sub-element of<item>*
<guid>is an optional sub-element of<item>.guid stands for globally unique identifier. It is a string that uniquely identifies the item. When present, an aggregator may choose to use this string to determine if an item is new.
<guid>http://some.server.com/weblogItem3207</guid>There are no rules for the syntax of a guid. Aggregators must view them as a string. It is up to the source of the feed to establish the uniqueness of the string.
If the guid element has an attribute named isPermaLink with a value of true, the reader may assume that it is a permalink to the item, that is, a url that can be opened in a Web browser, that points to the full item described by the
- element. An example:
<guid isPermaLink="true">http://inessential.com/2002/09/01.php#a2</guid>isPermaLink is optional, its default value is true. If its value is false, the guid may not be assumed to be a url, or a url to anything in particular.
Many readers use the <guid> to create a local cache entry for a feed item. If the <guid> changes, the feed reader uses this as a hint to fetch the article again. Given that <guid> has no rules for the contents, you might as well put a hash into the guid of the <description>.
I am using the <guid> for the signature, and the content of the <description> as the data to sign.
This solves two issues at once:
- Every item features its own signature via the
<guid>. - The
<guid>is supposed to be a unique identifier that acts as a hash over the data. A deterministic signature only changes if the content changes. Therefore, if the signature changes, feed readers are notified that they need to re-pull the article and show it as new.
For Atom feeds we can do everything analogous: Instead of signing <description> we will sign <summary> and store the signature in the mandatory <id> element of each entry.
Do not trust - just verify
Let's walk through the steps to verify the integrity of the webpage.
If you open the source code of this webpage you will see the signature block at the end of the code as:
<!DOCTYPE html>
<html lang="en">
...
</html>
<!-- SIGNATURE -->
Similarly, if you open the source code of my feed you will see feed items as:
<item>
<title>...</title>
<description>
...
</description>
<pubDate>...</pubDate>
<link>...</link>
<guid>SIGNATURE</guid>
...
</item>
Verifying the page or feed integrity relies on three observations:
- The content has a signature. If it does not, it can be considered forged.
- The content signature matches the respective content. If not, the content has been tampered with.
- The signature of the page matches the
KEY_ID. If another ID was used to sign it, someone went the extra mile to make it look legitimate.
To verify a page, we need to extract the base64 encoded signature at the end from the XML comment or the <guid> element, decode it, remove the comment from the page and the run verification on the remaining page, and in case of a feed item run it on the content of the <description> tag.
Let's prototype a test on a webpage. First, import my key. We can now use the following monstrosity to verify for instance the main page https://www.tobias-franke.eu:
gpg --verify <(curl -sS -L https://www.tobias-franke.eu | tail -n 1 | sed -nE 's/^<!--[[:space:]](.*)[[:space:]]-->$/\1/p' | base64 --decode) <(curl -sSL https://www.tobias-franke.eu | sed '$d' | perl -pe 'chomp if eof')
gpg: Signature made Sat Jul 11 12:28:01 2026 CEST
gpg: using EDDSA key 84900EB96B51B66F00F6FD8C7867B1E8DF18347A
gpg: Good signature from "Tobias Alexander Franke <tobias.franke@siggraph.org>" [ultimate]
This runs the verification, with the first input fetching the webpage, extracting the last line, extracting the comment, then decoding the base64 string, and the second input fetching the page again, extracting everything but the last line and making sure the last newline is also chopped off.
Putting it together
Everyone has their favorite flavor of static page generator, so rather than writing a Jekyll plugin I have simply made a postprocessing tool that will sign an HTML or XML file, where the XML file is assumed to be RSS or Atom.
The following Python tool wraps up all the above. It first determines the input type as either HTML or XML, then uses the appropriate parser to fetch the necessary metadata and call gpg to sign or verify the content. I tried to minimize dependencies, but there may be more compact ways to do the same.
You can find the script here.
Another small shell script iterates over the output directory of the static page generator to sign every .html and .xml file, and then verify the signature again.
#!/bin/sh
KEY_ID=...
PAGE_DIR=...
echo "Unlock gpg"
read -s PASS && echo "" | gpg --sign --yes --pinentry-mode=loopback --passphrase "$PASS" > /dev/null
echo "--- Processing page ---"
find $PAGE_DIR -type f -name '*.html' -print0 |
while IFS= read -r -d '' file; do
./sign-or-verify.py -s $file $KEY_ID
./sign-or-verify.py -v $file $KEY_ID
done
echo "--- Processing RSS feed ---"
find $PAGE_DIR -type f -name '*.xml' -print0 |
while IFS= read -r -d '' file; do
./sign-or-verify.py -s $file $KEY_ID
./sign-or-verify.py -v $file $KEY_ID
done
The Python script -v option is a little more contained than the original shell prototype and can be used to test a random URL against a key ID, where the URL is either a HTML document or an RSS feed.
$ sign-or-verify.py -v https://www.tobias-franke.eu/ 84900EB96B51B66F00F6FD8C7867B1E8DF18347A
gpg: Signature made Sat Jul 11 12:28:01 2026 CEST
gpg: using EDDSA key 84900EB96B51B66F00F6FD8C7867B1E8DF18347A
gpg: Good signature from "Tobias Alexander Franke <tobias.franke@siggraph.org>" [ultimate]
Conclusion
Deterministic signatures for webpages and feeds provide a solid way of ensuring their integrity.
For feed items however they solve two issues at once: Provide verification for the content of each feed item/entry, and at the same time serve as a hash for the feed reader to determine if the article was changed and needs to be re-fetched.
Ideally, we could advance the standard to identify the signature in a feed instead of using <guid isPermaLink="false"> so that the feed reader does not have to guess what the value means.
In the future I would like to add a kind of automatic integrity check to my page that gives the viewer a visual warning if the signature causes a mismatch.