💡 Full working example available on GitHub: qr-sign-password-protected-pdf-python

Introduction

There is a three-step pattern most teams reach for when a document that needs signing turns out to be encrypted: decrypt it, sign the plaintext, re-encrypt the result. It works. It also means that for a few hundred milliseconds a readable copy of a deliberately protected document exists in a temp directory, and in an audited pipeline that window is the finding rather than the signature.

Signing a protected PDF is a GroupDocs.Signature capability for Python via .NET that skips those three steps entirely: the password opens the source in place, the signature is applied, and the output is written back protected. This article compares the four password paths - two that work and two that fail on purpose - and covers the failure contract that is specific to this binding.

Why This Matters

Password handling is where document pipelines leak. Not through the signing library, usually, but through the scaffolding around it: the temp file that was supposed to be deleted, the exception handler that swallowed a wrong-password error and retried forever, the signed copy handed over with a password the recipient was never told about.

All three have the same root cause, which is that the password is treated as something to get out of the way rather than as part of the operation. LoadOptions and SaveOptions put it back in the operation.

Prerequisites

Python 3 and groupdocs-signature-net==26.1, plus a PDF with a user password. Without a licence the library runs in evaluation mode, which still signs but adds its own text to the page.

Installation

pip install groupdocs-signature-net==26.1

Method 1 - Keep the original password

The default, and the one that needs the least code. The password goes in through LoadOptions, and no SaveOptions is passed at all:

load_options = LoadOptions()
load_options.password = password
options = _build_qr_options(qr_text)
with signature.Signature(source_path, load_options) as sign:
    result = sign.sign(output_path, options)
    return len(result.succeeded)

The absence of SaveOptions is doing real work here. use_original_password defaults to True, so GroupDocs re-applies the source password to the signed output. There is no moment at which an unprotected version exists, on disk or otherwise, and len(result.succeeded) reports how many signatures were written.

Method 2 - Re-key the signed copy

When the signed document goes to a different party, the sensible move is to give the copy its own credential and leave the source alone:

save_options = SaveOptions()
save_options.password = new_password
save_options.use_original_password = False
with signature.Signature(source_path, load_options) as sign:
    result = sign.sign(output_path, options, save_options)
    return len(result.succeeded)

Both SaveOptions lines are required, and this is the detail worth remembering: setting password while leaving use_original_password at its default does nothing observable. The flag wins, the output keeps the old password, and you discover it when the recipient reports that the password you sent does not work.

Method 3 and 4 - The two failures

An encrypted document responds differently to a missing password and a wrong one, and the difference is worth handling.

With no LoadOptions at all, the open fails and nothing is written:

try:
    with signature.Signature(source_path) as sign:
        sign.sign(output_path, options)
    return ""
except RuntimeError as error:
    return proxy_error_name(error)

That returns PasswordRequiredException. Supply an incorrect password instead and the same code returns IncorrectPasswordException. One means ask the user for a credential; the other means the credential you have is stale. A handler that cannot tell them apart ends up retrying a password that will never work.

The failure contract, and why the obvious code breaks

Here is the part that costs an afternoon if nobody warns you. The binding exposes PasswordRequiredException, IncorrectPasswordException and GroupDocsSignatureException as bare names that do not inherit from BaseException. Write the intuitive handler:

except IncorrectPasswordException:
    ...

and Python raises TypeError: catching classes that do not inherit from BaseException is not allowed. The original error is gone, replaced by one that points at your except line rather than at the password. I wrote exactly that handler the first time, and the twenty minutes I spent reading the TypeError is the reason this section exists.

What actually arrives is a RuntimeError whose message begins Proxy error(<Name>): . Parsing that prefix recovers the cause:

message = str(error)
marker = "Proxy error("
if not message.startswith(marker):
    return ""
start = len(marker)
end = message.find(")", start)
if end < 0:
    return ""
return message[start:end]

Branch on the returned name rather than on the message text, which carries file paths and varies between runs.

Inspecting before you sign

There is a fifth path worth knowing, and it writes nothing at all. Opening the document with LoadOptions and calling get_document_info returns the format, page count and size while the file stays encrypted on disk:

with signature.Signature(source_path, load_options) as sign:
    info = sign.get_document_info()
    return info.file_type.file_format, info.page_count, info.size

Two uses for it. When the password came from a user form, this validates the credential on a cheap call rather than partway through a batch of two hundred documents. And when a pipeline is not permitted to store plaintext at all, it still lets that pipeline report on what it is holding - page counts for an audit log, sizes for a quota - without decrypting anything.

Comparing the Methods: When to Use Each

Method Best For Key Advantages Limitations
Keep original password pipelines that sign in place no SaveOptions, nothing written in the clear recipient needs the source password
Re-key on save handover to another party source keeps its credential, copy gets a new one two SaveOptions lines, easy to set only one
No password (fails) proving the contract in tests fails on open, writes nothing not a signing path
Wrong password (fails) distinguishing a stale credential distinct exception name not a signing path

Is the read-back worth the extra call?

Yes, for two reasons. Reopening the signed file with QrCodeVerifyOptions proves the signature survived the save, and because the reopen has to supply the password, it also proves the output really is still encrypted. A zero count is almost always a licensing problem rather than a signing failure - the sign call raises when it genuinely fails, so silence plus zero matches points at an unlicensed build.

What it costs to switch

Nothing structural. If your code already decrypts to a temp file, the change is deleting that step, moving the password into LoadOptions, and removing the re-encrypt call at the end - typically a net loss of lines. The signing call itself does not change shape, and the output is byte-for-byte a signed PDF with the same protection it had going in.

The one place to look carefully is cleanup code. A pipeline built around decrypt-sign-reencrypt usually has a finally block that deletes the temp file, and once the temp file is gone that block is deleting a path that no longer exists.

Best Practices

  • Leave use_original_password alone unless you are deliberately rotating; the default is the safe one.
  • Parse the proxy name once, in a helper, and branch on it everywhere else.
  • Validate a user-supplied password with get_document_info before starting a batch, so a bad credential costs one cheap call instead of a half-finished run.
  • Never write the signed output over the source path, so a mistake leaves the original recoverable.

Conclusion

The password is not an obstacle to work around before signing - it is an argument to the operation. Open with LoadOptions, decide the output protection with SaveOptions, parse the proxy name when something fails, and verify through the password afterwards. The sample runs all four paths in one go, so the difference between them takes a single command to see rather than a paragraph to trust.

Additional Resources