đź’ˇ Full working example available on GitHub: vault-transit-pdf-signing-python
Why Automated Signing Fails a Key-Custody Review
Compliance and finance operations teams sign documents by the thousand: loan agreements, statements, policy packs, audit letters. Almost none of that is signed by a person clicking a button. A service does it, on a schedule, and the certificate attached to those documents carries the organisation’s name. That makes the signing key one of the most sensitive things the company owns, and it usually sits in a PFX file beside the application that uses it.
External signing is a GroupDocs.Signature capability for Python via .NET that passes the document digest to code you supply instead of signing with a private key the application holds. It exists because the PFX arrangement does not survive scrutiny. A file is a copy, and copies spread: into the container image, the deployment pipeline, the backup, the developer laptop that debugged a production issue last spring. Anyone holding one of those copies and the password can sign in your name until somebody notices and revokes the certificate.
What these teams need is not a different file format. They need the key to exist in exactly one place, with an audit trail of every time it was used, and a signing service that can ask for a signature without ever being trusted with the key. That is what a key manager such as HashiCorp Vault is for, and it only helps if the document library can be told to delegate the cryptography.
Why a Password-Protected Certificate File Is Not Enough
The instinct is to lock the PFX down: restrict file permissions, store the password in a secret manager, encrypt the volume. Each step helps and none of them changes the core fact. At the moment of signing, the private key is loaded into the process, so a memory dump, a container escape or a careless log statement can still expose it. The control you want is a key that cannot be read at all, not a key that is harder to read.
Vault’s Transit engine provides that. It performs cryptography as a service: you send a value, it
signs or encrypts with a key it holds, and returns the result. Keys created inside it can be
marked non-exportable, and it never stores what it receives. GroupDocs.Signature covers the other
half, because DigitalSignOptions.custom_sign_hash lets you plug your own signer into the PDF
signing pipeline.
The Setup: GroupDocs.Signature for Python with Vault Transit
GroupDocs.Signature does everything up to the cryptography. It opens the PDF, decides which byte range the signature covers, hashes that range, and calls your signer with the digest. Your signer hands the digest to Vault and returns the bytes Vault produced. The library embeds them together with the certificate and writes a normal signed PDF.
You need Python 3.11 or 3.13, the groupdocs-signature-net and asn1crypto packages, and Docker
for a dev-mode Vault container. A GroupDocs licence is optional; without one, evaluation mode
accepts documents of up to 2 pages. The sample repository runs end to end in two commands, and
the same code points at a production Vault by changing two environment variables.
Building It Step by Step
Step 1 - Read the signing key from Vault
The application asks Vault for the key it is allowed to use, and gets back a version number and a public key. The private half is never part of the answer.
key = vault.read_key(KEY_NAME)
if key is None:
# First run: enable Transit and create an RSA-2048 key.
vault.enable_transit()
vault.create_key(KEY_NAME, "rsa-2048")
key = vault.read_key(KEY_NAME)
print(f"Signing key: '{KEY_NAME}', version {key.version}")
Reading the version once is the point of this step. A certificate holds one public key, so the certificate and every signature must come from the same version.
Step 2 - Build a certificate for a key nobody can export
A PDF signature carries an X.509 certificate so a verifier can find the public key. With a non-exportable key, the certificate is assembled around the public half and signed by Vault.
tbs_certificate = x509.TbsCertificate({...}) # built around the Vault public key
# Hash locally, then have Vault sign the certificate's own digest.
digest = hashlib.sha256(tbs_certificate.dump()).digest()
signature = vault.sign_hash(key_name, key.version, digest, "sha2-256")
certificate = x509.Certificate({
"tbs_certificate": tbs_certificate,
"signature_algorithm": algorithm,
"signature_value": signature,
})
The certificate deliberately starts one day earlier than now. A PDF reader compares the signing time with that start date, and on a machine two hours east of UTC a certificate minted seconds before signing was reported by Adobe Acrobat as expired or not yet valid.
Step 3 - Sign the PDF through the external signer
certificate_object = DigitalVerifyOptions(
certificate_stream=io.BytesIO(certificate)).certificate
digital_signature = DigitalSignature(certificate=certificate_object)
digital_signature.sign_time = datetime.now().astimezone()
options = DigitalSignOptions()
options.signature = digital_signature
options.custom_sign_hash = VaultSigner(vault, KEY_NAME, key.version)
options.hash_algorithm = HashAlgorithm.SHA256
options.reason = "Document signed with HashiCorp Vault Transit"
with gs.Signature(str(input_pdf)) as signature:
signature.sign(str(output_pdf), options)
Two lines are specific to Python. The binding has no X.509 type, so the certificate object comes
back out of DigitalVerifyOptions; handing a path to DigitalSignOptions instead raises
“Specified certificate doesn’t contain private key”. And sign_time is time-zone aware on
purpose, because a value with an offset is written into the PDF with it.
Inside the signer, the digest goes to Vault with prehashed = true and pkcs1v15. Those two
fields decide whether the signature is valid, and the first one fails silently when it is
missing.
Step 4 - Verify before anything leaves the service
options = DigitalVerifyOptions(certificate_stream=io.BytesIO(certificate))
with gs.Signature(str(signed_pdf)) as signature:
is_valid = signature.verify(options).is_valid
Signing and verifying in the same run turns the one silent failure mode into an immediate one. For a document pipeline this is the gate: a file that does not verify never reaches the archive, the counterparty or the customer portal.
Business Impact: What Changes for the Audit
| Concern | PFX on the application host | Signing through Vault Transit |
|---|---|---|
| Copies of the private key | one per host, image, backup and laptop | one, inside Vault |
| Rights the signing app needs | full access to the key file | read the key, call transit/sign |
| Evidence a document was signed | application logs, if someone added them | one Vault audit entry per signature |
| Response to a suspected leak | revoke, re-issue, redeploy everywhere | rotate the key in Vault, re-issue once |
| Who can sign in your name | anyone with the file and the password | anyone holding a valid Vault token |
The last row is the honest one. This design moves the secret rather than removing it: a stolen Vault token can still request signatures. The difference is that a token is short-lived, revocable in one place, scoped to two operations, and every use of it is logged. That is a materially easier thing to defend in a review than a file whose copies nobody can enumerate.
Real Workflow: a Loan Agreement Signed by a Service
A lending platform generates an agreement PDF when an application is approved, signs it, and drops it into the customer’s document vault. Before this change, the signing service held the company certificate and the deployment runbook had a step for copying the PFX onto new hosts. After it, the service holds an AppRole token with two permissions, and the certificate’s private key has never existed anywhere except Vault.
When auditors ask how many documents were signed last quarter and by what, the answer comes from Vault’s audit device rather than from reconstructing application logs. When the team rotates the key, they rotate it in one place and issue a new certificate for the new version.
What should I get right first?
The digest handling. A wrong algorithm name fails loudly, with Vault answering
500 crypto/rsa: input must be hashed message, and costs a minute. A missing prehashed flag
produces no error at all: Vault hashes the digest again, the signature covers the wrong value,
and the PDF looks finished. Verify every file you sign and that failure stops being invisible.
Other Places This Design Fits
The hook has no idea what is behind it, so the same four steps work against a hardware security module, a cloud KMS or a smart card. I ported this build from its .NET sibling expecting the signer to need rework, and the only part that genuinely changed was the certificate hand-off; the Vault request body and the version pinning were identical. Teams in healthcare, government and insurance reach for it for the same reason finance does: the key has to be somewhere that can prove what it was used for.
Getting Started
- Start free: get a trial licence, full API access
- Read the integration guide: signing PDFs with a Vault Transit key in Python
- Browse the code: vault-transit-pdf-signing-python
- Ask implementation questions on the community forum