Signing and verification¶
pypi-profile uses minisign to let a
profile owner prove they control external accounts — a GitHub profile, a
Mastodon bio, a personal website, and so on.
This page explains how the mechanism works, what it actually proves, and where it can fail or be misunderstood.
What problem this solves¶
Publishing a profile package to PyPI already proves one thing: the PyPI account
holder put that data there. A reader can check the PyPI release history and know
that whoever controls pypi-profile-example on PyPI is the author of the data.
The open question is cross-account linking. A profile can claim:
[[profiles]]
kind = "github"
url = "https://github.com/example"
But that claim is self-asserted. Anyone who publishes a pypi-profile-*
package can list any GitHub URL. There is nothing stopping a bad actor from
claiming someone else's account.
Signed proofs solve this. The idea is that the same person who controls the PyPI account also places a short signed token on the external page. A reader who fetches both can check that the signature was made with the key embedded in the PyPI package, and therefore that the same entity controls both places.
How minisign works¶
Minisign is a small, opinionated signing tool built on the Ed25519 algorithm. Ed25519 is a modern elliptic-curve signature scheme. It has these relevant properties:
- Key generation produces two files. A secret key (never shared) and a public key (safe to publish).
- Signing is a one-way operation. You feed data to the secret key and get back a signature blob. The secret key is never transmitted.
- Verification needs only the public key. Anyone with the public key and the original data can check whether the signature is genuine. No secret is involved in verification.
- Signatures are unforgeable without the secret key. If a signature verifies correctly against a public key, the data was signed by whoever holds the matching secret key.
- There is no central authority. Minisign does not use a certificate chain or a trusted third party. Trust is rooted entirely in whoever published the public key.
py-minisign is the Python library that provides these operations. It is an
optional dependency; the base pypi-profile package works without it, but the
keygen, sign, and verify commands require it.
The signing flow, step by step¶
1. Generate a keypair¶
pypi-profile keygen
This writes two files under ~/.pypi_profile/:
~/.pypi_profile/minisign.key ← secret key, never share this
~/.pypi_profile/minisign.pub ← public key, paste this into your TOML
The command prints the public key as a base64 string. Copy it into your profile:
[verification]
public_key = "RWQ..."
preferred_signature_backend = "minisign"
The public key is safe to publish anywhere. Treat the secret key like a password: keep it private, back it up, never commit it to version control.
2. Sign a claim for an external URL¶
pypi-profile sign controls-url pypi_profile.toml \
--url https://github.com/example
Internally, this builds a JSON claim document:
{
"profile_package": "pypi-profile-example",
"pypi_username": "example",
"claim": "controls-url",
"subject": "https://github.com/example",
"issued_at": "2026-05-10T12:00:00Z",
"expires_at": "2027-05-10T12:00:00Z",
"nonce": "550e8400-e29b-41d4-a716-446655440000",
"key_id": "AABBCCDDEEFF0011",
"signature_backend": "minisign"
}
That document is serialized to canonical JSON bytes, signed with the secret key,
and the resulting minisign signature blob is embedded as a signature field.
The whole thing is then base64url-encoded into a single-line token:
pypi-profile-proof: eyJwcm9maWxlX3BhY2thZ2UiOiAi...
3. Paste the token onto the external page¶
Place the token somewhere on the page you are claiming. For example:
- In a GitHub profile README
- In a Mastodon post (see Mastodon workflow below)
- In a GitLab profile description
- In the text of a personal website
- In a project README on PyPI
The token is plain text and intentionally compact. It does not matter whether it appears in a comment, a paragraph, a code block, or a metadata field, as long as the raw text is accessible to an HTTP fetch.
4. Store proofs in the TOML for static builds¶
If you publish a static site (via pypi-profile build), the private key is not
available at build time. Run update-proofs locally after signing to store the
proof strings in the TOML:
pypi-profile update-proofs pypi_profile.toml
This signs every [[profiles]] entry that does not yet have a stored_proof
and writes the proof strings directly into the TOML file. Commit the result.
The static build reads stored_proof instead of calling the private key.
To re-sign everything (e.g. after key rotation):
pypi-profile update-proofs pypi_profile.toml --force
5. Verify¶
pypi-profile verify pypi_profile.toml
The verifier:
- Reads
public_keyfrom[verification]. - For each
[[profiles]]entry, fetches the URL. - Scans the page text for any line matching
pypi-profile-proof: <token>. - Decodes each token it finds.
- Checks that
subject,pypi_username, andprofile_packagematch what is in the profile. - Checks that
expires_athas not passed. - Verifies the minisign signature against the embedded public key.
Results are reported as verified, unverified, invalid, or expired.
The same check runs live when a visitor loads /verification in the served site
and appears in /api/verification.json.
What a verified claim actually proves¶
A verified result means:
The entity that published
pypi-profile-exampleto PyPI, under the PyPI accountexample, also placed a correctly signed token athttps://github.com/example— signed with the private key whose public counterpart appears in that same PyPI package.
This is a meaningful proof of co-control. It is not a weak claim. Producing the token requires both:
- write access to the external page, and
- possession of the secret key corresponding to the public key in the PyPI package.
Both conditions must be true simultaneously. Neither alone is sufficient.
What a verified claim does NOT prove¶
This is the part that is easy to misread.
It does not prove legal identity¶
The claim connects a PyPI account, a cryptographic key, and an external URL. It says nothing about a legal name, a physical address, an employer, or any government-issued identity document.
The profile data may contain a legal name, but that field is self-asserted.
pypi-profile has no way to verify it.
It does not prove the content of the external page is accurate¶
The claim proves the profile owner controls https://github.com/example. It
does not prove that the GitHub bio, README, or repository list accurately
represents the person's skills, employment, or professional history.
It does not prove the PyPI package maintainer list is controlled by one person¶
A PyPI project may have multiple maintainers. Publishing a profile package proves one account published the data, not that the entire named project belongs exclusively to that account.
It does not prove the linked external account has not been compromised¶
If the GitHub account was later taken over by someone else, an old proof token on that page could still verify correctly — because the token only proves that the page was controlled by the keyholder when the token was placed, not that it still is.
Expiry dates mitigate this: a proof with a one-year expiry stops verifying automatically after that window. Shorter expiry windows reduce exposure at the cost of requiring more frequent re-signing.
It does not prove absence of malicious intent¶
Verification is a proof of technical co-control, not a character reference. Someone who has verified every claim on their profile is not thereby more trustworthy in a human or professional sense.
It does not make self-asserted data verified¶
Fields like work_experience, skills, and contact_methods remain
self-asserted regardless of whether other claims are verified. The UI labels
them accordingly. Do not conflate "this profile has some verified claims" with
"everything in this profile is verified."
The trust model in plain terms¶
Think of it as a chain of co-control, not a certificate authority hierarchy.
PyPI account (publishes pypi-profile-example)
└─ embeds public key ABCD1234
└─ secret key ABCD1234 signed the token at github.com/example
└─ token is present at github.com/example
Breaking any link in that chain breaks the proof:
| Link | What breaks it |
|---|---|
| PyPI account | Someone publishes a profile package from a different PyPI account |
| Public key | The profile TOML contains a public key that does not match the signing key |
| Token | The external page does not contain a valid token for this profile |
| Expiry | The token's expires_at is in the past |
| Signature | The signature was produced with a different secret key |
Mastodon workflow¶
Mastodon posts have a URL, but that URL is not known until after the post is created. The recommended workflow is:
-
Create a blank post (or a placeholder post) on Mastodon.
-
Copy the post URL from the browser address bar, e.g.
https://fosstodon.org/@yourname/123456789. -
Add it to your TOML:
toml
[[profiles]]
kind = "mastodon"
label = "Mastodon"
url = "https://fosstodon.org/@yourname/123456789"
verification = "self_asserted"
rel_me = true
- Sign it:
bash
pypi-profile sign controls-url pypi_profile.toml \
--url https://fosstodon.org/@yourname/123456789
-
Edit the Mastodon post to include the
pypi-profile-proof: ...token that was printed. Mastodon allows editing posts. -
Store the proof in the TOML so static builds work without the private key:
bash
pypi-profile update-proofs pypi_profile.toml
- Verify:
bash
pypi-profile verify pypi_profile.toml
Note: The verifier fetches the post URL as plain HTML. Mastodon renders post content in the server-side HTML, so the token will be found without requiring JavaScript.
rel="me" links¶
Mastodon and some other platforms use the HTML rel="me" attribute to establish
bidirectional identity links between accounts. When a Mastodon profile contains
<a rel="me" href="https://example.com">, and the linked page contains
<a rel="me" href="https://mastodon.social/@you">, Mastodon marks the link as
verified.
To opt a profile link into rel="me" output, set rel_me = true:
[[profiles]]
kind = "mastodon"
label = "Mastodon"
url = "https://fosstodon.org/@yourname/123456789"
rel_me = true
[[profiles]]
kind = "github"
label = "GitHub"
url = "https://github.com/yourname"
rel_me = true
The served and built pages will render the corresponding anchors as
<a href="..." rel="me">. This is independent of the minisign proof-of-control
mechanism — you can use rel_me with or without a stored_proof.
What can go wrong¶
Stale tokens¶
A proof token is valid until expires_at. If you change your public key (for
example, because the secret key was lost or compromised), old tokens signed with
the old key will no longer verify against the new public key. You must re-sign
all external claims with the new key and re-paste the new tokens.
The key_id field in the token records which key produced the signature, so
tooling can report "signed by an old key" rather than just "invalid."
Key loss¶
If you lose the secret key, you cannot sign new claims. Use key-recover to
automate the recovery workflow: it diagnoses the missing key, generates a
replacement, updates public_key in your TOML, and re-signs all stored proofs.
pypi-profile key-recover pypi_profile.toml
After recovery, commit the updated TOML and update any external pages that embedded the old proof strings. Those pages will list which URLs need attention.
When keyring is installed and a usable backend is active (macOS Keychain,
Windows Credential Manager, libsecret on Linux), pypi-profile keygen
automatically stores the secret key there in addition to the disk file at
~/.pypi_profile/minisign.key. Subsequent sign and update-proofs commands
load the key from the keyring first and fall back to disk.
If you are not using a keyring backend, back up
~/.pypi_profile/minisign.key to an encrypted location (password manager,
encrypted archive). Never commit it to version control.
Secret key exposure¶
If the secret key is exposed — for example committed to a public repository by accident — any holder can sign claims on your behalf. Generate a new keypair immediately, update the profile, and republish.
Old claims signed by the compromised key will continue to verify until they expire or until you remove the old public key from the profile.
URL content is fetched at verify time¶
The verifier fetches the external URL at the moment verify is run (or when a
visitor loads /verification). If the external page is slow, returns an error,
or requires JavaScript to render content, the fetch will fail and the result
will be unverified rather than verified.
Plain-text placement of the token is more reliable than token placement inside JavaScript-rendered content.
External pages that redirect¶
The verifier follows HTTP redirects. The subject URL in the claim must match
the declared URL in [[profiles]], not the final redirect destination.
If your GitHub profile redirects, make sure the declared URL is what the verifier
will use for the match check.
Token placed in a section of the page not returned by HTTP¶
Some profile services return rich HTML that contains visible bios only after a JavaScript render. The verifier performs a plain HTTP GET, not a headless browser fetch. If the token is only present in dynamically loaded content, it will not be found.
For services like GitHub and Mastodon where the bio is in the raw HTML, this works. For single-page apps that require JavaScript, it may not.
Common misunderstandings¶
"I have a verified badge, so readers should trust everything in my profile"¶
No. The verification badge on /verification means some external URLs were
confirmed as co-controlled with the PyPI account. It says nothing about
self-asserted fields like employment history, skills, or contact methods.
"If the claim says verified, PyPI has endorsed this person"¶
No. pypi-profile is an independent tool. Verification is done by
pypi-profile's own verifier. PyPI has not endorsed or reviewed the profile.
"Other people can verify my profile"¶
Yes — that is the design. Anyone who runs pypi-profile verify against your
published profile package, or visits /verification on your served site, will
see the same live check. The mechanism is transparent and publicly auditable.
"I need to keep the proof token secret"¶
No. The token is designed to be pasted publicly. It contains no secret information. The signature embedded in it is produced by the secret key, but it cannot be used to recover the secret key.
"Putting the token in my GitHub README makes GitHub responsible for the claim"¶
No. GitHub is only a hosting medium. The claim proves that the token was placed there by the holder of the private key; it does not involve GitHub in any verification capacity.
Key management commands¶
| Command | Purpose | Modifies |
|---|---|---|
key-info |
Inspect the active key and its profile binding | no |
key-list |
List all keys across keyring and disk | no |
key-rotate |
Replace the active key and re-sign all proofs | yes |
key-recover |
Recover from a lost key | yes |
key-export --output FILE |
Export the raw key bytes to a file | no |
key-import FILE |
Install an exported key into keyring / disk | yes |
All write commands accept --dry-run to preview what would change.
Use key-export / key-import to move a key to a new machine or set up a CI
signing key without rotating.
Security checklist¶
- [ ] Install
pypi-profile[sign]so thatkeyringandpy-minisignare available - [ ] Generate your keypair once with
pypi-profile keygen - [ ] Confirm
pypi-profile doctorshows a keyring backend and "Secret key found in keyring" - [ ] Confirm
pypi-profile key-inforeports the expected key ID and a matching TOML binding - [ ] If no keyring backend is available, back up
~/.pypi_profile/minisign.keyto an encrypted location - [ ] Never commit the secret key file to version control
- [ ] Add the printed public key to
[verification]in your TOML - [ ] Republish your profile package so the public key is in the PyPI release
- [ ] Run
pypi-profile sign controls-urlfor each external URL you want to claim - [ ] Paste each proof token onto the corresponding external page
- [ ] Run
pypi-profile update-proofsto store proofs in the TOML for static builds - [ ] Commit the updated TOML (the stored proofs contain no secret material)
- [ ] Run
pypi-profile verifyto confirm the round-trip works - [ ] Set a calendar reminder to re-sign before tokens expire (default: 1 year)
- [ ] To rotate your key, run
pypi-profile key-rotate— it re-signs all claims automatically - [ ] If you lose your key, run
pypi-profile key-recover— it generates a replacement and re-signs
Signing dependencies¶
py-minisign and keyring are core dependencies of pypi-profile — no extra
is required. A plain install includes both:
pipx install pypi-profile
Run pypi-profile doctor to confirm both are present and that a keyring backend
is active.
Keyring backend availability¶
| Platform | Default backend |
|---|---|
| macOS | Keychain |
| Windows | Credential Manager |
| Linux (GNOME/KDE) | libsecret / KWallet via SecretService |
| Linux (headless CI) | No usable backend — disk fallback is used |
On headless Linux (e.g. GitHub Actions), no keyring backend is available. The
secret key will be loaded from disk only. Do not store the secret key in CI
environments; use update-proofs locally and commit the stored proofs instead.
Overriding the keyring username¶
If you manage multiple profiles on the same machine, set
PYPI_PROFILE_KEYRING_USERNAME to a different value per profile:
PYPI_PROFILE_KEYRING_USERNAME=mycompany pypi-profile keygen
PYPI_PROFILE_KEYRING_USERNAME=mycompany pypi-profile update-proofs pypi_profile.toml