đź’ˇ Full working example available on GitHub: pdf-signing-certificate-checks-python

Introduction

A service signs uploaded PDFs every night. One morning the certificate it uses passes its expiry date, and nothing appears to change: the job runs, the files are written, the log looks normal. Weeks later somebody opens one of those documents in Acrobat and sees a warning banner, because a signature made with an expired certificate is not a weaker signature - it is one that validators report as invalid. The documents that look approved are worth less than unsigned ones, because people believed them.

That refusal has a name. Certificate validity checking is a GroupDocs.Signature behaviour for Python that declines to sign once the certificate’s validity period has lapsed, or before it begins. It arrived in version 26.9 alongside two changes with the same shape: SHA-256 became the default digest for PDF signatures, and SignatureSettings.log_level started filtering instead of being quietly ignored. Each one takes an outcome that used to happen silently and puts it in front of you.

This article compares those three controls as they behave from Python via .NET - what each one changes in the output, when to reach for it, and which two details of the binding cost people an afternoon. Every result quoted comes from running the sample against a one-page PDF.

Why This Matters More Than a Version Note

The three changes share a property worth naming: all of them convert a failure you would discover later into one you discover now.

  • Expired certificates: the signing call fails where somebody can renew the certificate, instead of producing documents that fail validation after distribution
  • Digest defaults: new signatures use SHA-256 without anyone remembering to ask, so the weak option requires a decision rather than inattention
  • Log levels: a service that configures warnings-only now receives warnings only, which makes the warnings readable, which means they get read

That last one is less cosmetic than it sounds. The whole value of the expired-certificate warning is that somebody sees it, and a warning buried in ten trace messages per signing run is a warning nobody sees.

Prerequisites

Before starting, ensure you have:

  • Python 3.9 or later on a 64-bit interpreter - the package ships a bundled .NET runtime and has no 32-bit wheel
  • GroupDocs.Signature for Python via .NET 26.10.0, with a free temporary licence if you want to remove the evaluation limits
  • A PDF to sign, and the cryptography package if you want to build throwaway test certificates as the sample does

Installation

pip install groupdocs-signature-net cryptography

Control 1 - The digest written into the signature

hash_algorithm on DigitalSignOptions picks the digest. The default since 26.9 is SHA-256, in the adbe.pkcs7.detached format current validators expect; before that, new signatures were SHA-1.

with signature.Signature(source_path) as sign:
    options = DigitalSignOptions()
    options.certificate_stream = io.BytesIO(pfx)
    options.password = PASSWORD
    options.hash_algorithm = HashAlgorithm.SHA512
    options.reason = "Approved"

    result = sign.sign(output_path, options)
    return len(result.succeeded)

Two details are worth drawing out. The certificate arrives through certificate_stream as an io.BytesIO rather than as a file path, which is how a PKCS#12 built in memory reaches the library without ever being written to disk - the sample relies on that so it can ship no private key at all. And HashAlgorithm offers AUTO, SHA1, SHA256, SHA384 and SHA512, where a time stamp, if you add one, uses whichever digest the signature used.

In practice this is the control you touch least. The default is already the right answer, SHA384 and SHA512 exist for when a signing policy names them, and SHA1 is a compatibility setting for validators you cannot change.

Control 2 - Whether an out-of-date certificate stops you

With no overrides, signing with a certificate whose validity period has ended - or has not started - raises GroupDocsSignatureException and writes nothing at all.

try:
    sign.sign(output_path, options)
    return True
except signature.GroupDocsSignatureException as error:
    print(f"Rejected: {str(error).splitlines()[0]}")
    return False

The message names the certificate, the date it expired, its thumbprint and the property that would permit it, which is enough for an application to tell an operator what to renew. Taking only the first line matters in Python specifically: the exception’s text continues with the .NET stack trace from behind the binding, and that is not something to put in front of a user.

When you genuinely need to sign anyway - a test against an archived certificate, or a batch that must run tonight while renewal is in flight - the override is per call:

settings = signature.SignatureSettings(ConsoleLogger())
settings.log_level = LogLevel.WARNING | LogLevel.ERROR

with signature.Signature(source_path, settings=settings) as sign:
    options.allow_expired = True
    result = sign.sign(output_path, options)

allow_not_yet_valid is the same shape for a certificate issued for a later date, and the two flags are independent: allowing an expired certificate does not allow an early one. An early certificate usually means the machine’s clock is wrong rather than the certificate being unusual, and a wrong clock makes every signature that machine produces questionable, so check that before overriding anything.

Both overrides emit a warning rather than passing silently, which is the part that connects to the third control.

Control 3 - Whether anyone finds out

SignatureSettings.log_level is a flags value. The sample signs the same document three times, under LogLevel.NONE, LogLevel.WARNING | LogLevel.ERROR and LogLevel.ALL, counting what arrives:

logger = CollectingLogger()
settings = signature.SignatureSettings(logger)
settings.log_level = level

with signature.Signature(source_path, settings=settings) as sign:
    options.allow_expired = True
    sign.sign(output_path, options)

The counts come out as nothing at all, then one warning, then that warning plus ten traces. Before 26.9 all three rows would have been identical, because the level was accepted and ignored - which is worth knowing if you ever set one, saw no change, and concluded you had misread your own code.

Two binding details cost me an afternoon between them, so they are worth stating plainly. SignatureSettings.logger is read-only, so the logger is a constructor argument and assigning to it raises AttributeError; log_level is set normally afterwards. And a custom logger must not subclass groupdocs.signature.logging.ILogger - that base class wraps a native object whose constructor needs a handle the library owns, so subclassing raises TypeError. The binding marshals any plain object that provides the three methods:

class StdlibLogger:
    def error(self, message, exception=None):
        logging.getLogger("groupdocs").error(message, exc_info=exception)

    def warning(self, message, exception=None):
        logging.getLogger("groupdocs").warning(message)

    def trace(self, message):
        logging.getLogger("groupdocs").debug(message)

Give error and warning an optional exception parameter. The library does not always pass one, and a logger that requires it breaks on the messages that omit it.

Comparing the Three: When to Use Each

Control Best for Key advantages Limitations
hash_algorithm meeting a policy that names a digest one assignment; same output size pointless if the certificate itself is untrusted
validity check and overrides anything signing for other people failure lands where it can be fixed an override produces a file, not a trustworthy one
log_level services whose logs are already busy eleven messages become one filters logging only, never exceptions

They are not alternatives - a single signing call uses all three. The order to think about them in is the order of consequence: the validity check decides whether a file exists, the digest decides what is inside it, and the log level decides who knows.

Does the log level change which exceptions I get?

No. It decides which messages reach your logger and nothing more. An expired certificate still raises GroupDocsSignatureException under LogLevel.NONE, and allow_expired still signs under LogLevel.ALL; return values and exceptions are identical across every level. What changes is whether the warning that explains a questionable signature is ever read by a person.

Verification Moved in the Same Direction

Worth a mention because it is the other half of the same release. verify with an empty DigitalVerifyOptions now checks every PDF digital signature cryptographically, so a document altered after signing comes back invalid rather than merely unexplained:

with signature.Signature(signed_path) as sign:
    result = sign.verify(DigitalVerifyOptions())
    return result.is_valid

Two lines, and worth adding to any pipeline that signs and then stores. Note what a True does not promise: it says the signature matches the document, not that the issuer is trusted. The sample’s self-signed certificates verify here and are still refused by a PDF reader, which answers the trust question separately.

Best Practices and Tips

  • Keep the refusal as the default in anything that signs on behalf of users, and override per call rather than globally. The exception is cheap; a batch of invalid signatures is not.
  • Log the warning text, not just a counter. It names the certificate and the date, which is the only part an operator can act on.
  • Check the clock before allowing a not-yet-valid certificate. The certificate is usually right and the machine is usually wrong, and that affects more than one signing call.
  • Keep traces out of production. Around ten per signing run adds up quickly; switch them on while diagnosing and off afterwards.
  • Verify after signing in any pipeline, now that the check is cryptographic, so a corrupted output is caught before a recipient finds it.

Conclusion

Three controls, one signing call, and the same design idea behind all of them: the risky outcome now needs a decision, and the safe one needs nothing. Keep the validity check, treat allow_expired as a per-call exception you log, leave the digest alone unless a policy says otherwise, and set a log level that makes the warnings legible.

Running the sample against one of your own PDFs takes a minute and prints exactly what each control changed - six signed files, one deliberate refusal, and three rows of message counts that no longer look the same.

Additional Resources