---
title: "Signing a PDF"
description: "A complete PAdES signature with a cloud certificate through CloudHub, with PKI SDK, Rest PKI or PKI Express building the signed PDF, in C#, PHP, Python, Node.js and Java."
canonical: "https://docs.lacunasoftware.com/en-us/articles/cloudhub/pades-signature"
locale: "en"
product: "CloudHub"
last_updated: "2026-10-12"
---

# Signing a PDF

This page puts the whole flow together: a PAdES signature of a PDF with a cloud certificate, in C#,
PHP, Python, Node.js and Java. CloudHub provides the certificate and the signature; a signature
library builds the signed PDF. Each example follows the matching sample in
[PKI Suite Samples](https://github.com/LacunaSoftware/PkiSuiteSamples).

| Language | Signature library | Web framework |
|---|---|---|
| C# | [PKI SDK](https://docs.lacunasoftware.com/en-us/articles/pki-sdk) | ASP.NET Core MVC |
| PHP | [Rest PKI](https://docs.lacunasoftware.com/en-us/articles/rest-pki) | plain PHP |
| Python | [PKI Express](https://docs.lacunasoftware.com/en-us/articles/pki-express) | Flask |
| Node.js | [Rest PKI](https://docs.lacunasoftware.com/en-us/articles/rest-pki) | Express |
| Java | [Rest PKI](https://docs.lacunasoftware.com/en-us/articles/rest-pki) | Spring MVC |

Each signature library needs its own setup first: a license for PKI SDK, an access token for Rest
PKI, PKI Express installed on the server. The examples read the Rest PKI token from
`RESTPKI_ACCESS_TOKEN` and the CloudHub key from `CLOUDHUB_API_KEY`.

## How the pieces fit

Every example has two handlers:

- **discover** receives the signer's CPF, creates a CloudHub session and lists the providers, each
  linked to its `authUrl`. It also stores the CPF in the user's web session, to check the certificate
  later.
- **complete** is the redirect URI. It runs four steps:

1. Read the signer's certificate from CloudHub.
2. Give the certificate to the signature library, which **starts** the signature and returns the
   hash to sign, its digest algorithm, and what it needs to finish (transfer data, a token or a
   transfer file).
3. Send the hash to CloudHub's `sign-hash`. The provider signs it with the user's key.
4. Give the signature to the signature library, which **finishes** the signed PDF. Then check that the
   signer's CPF is the one you stored.

The signature libraries return the hash in different encodings. CloudHub takes it base64-encoded on
the wire, and the client libraries convert from the type they accept:

| Example | The hash from the signature library | What the CloudHub client takes |
|---|---|---|
| C#, PKI SDK | bytes, from `DigestAlgorithm.ComputeHash` | bytes |
| PHP, Rest PKI | raw bytes in `$start->toSignHash` | base64: wrap it in `base64_encode` |
| Python, PKI Express | base64 in `start['toSignHash']` | base64 |
| Node.js, Rest PKI | base64 in `start.toSignHash` | base64 |
| Java, Rest PKI | bytes, from `getToSignHashRaw()` | bytes |

## The example

**C#**

```csharp title="Controllers/CloudSignatureController.cs"
using System.Text.RegularExpressions;
using Lacuna.Cloudhub.Api;
using Lacuna.Cloudhub.Client;
using Lacuna.Pki;
using Lacuna.Pki.Pades;
using Microsoft.AspNetCore.Mvc;

public class CloudSignatureController : Controller {

    private static readonly CloudhubClient cloudhub = new CloudhubClient(
        "https://cloudhub.lacunasoftware.com/",
        Environment.GetEnvironmentVariable("CLOUDHUB_API_KEY"));

    // POST /CloudSignature/Discover: the form with the signer's CPF
    [HttpPost]
    public async Task<IActionResult> Discover(string fileId, string cpf) {
        cpf = DigitsOnly(cpf);
        // Remember whose signature this is, to check the certificate later
        TempData["cloudhubCpf"] = cpf;

        var session = await cloudhub.CreateSessionAsync(new SessionCreateRequest {
            Identifier = cpf,
            RedirectUri = Url.Action(nameof(Complete), "CloudSignature", new { fileId }, Request.Scheme),
            Type = TrustServiceSessionTypes.SingleSignature,
        });

        // The view links each entry of session.Services to its AuthUrl
        return View(session.Services);
    }

    // GET /CloudSignature/Complete?fileId=…&session=…: the redirect URI
    public async Task<IActionResult> Complete(string fileId, string session) {
        if (string.IsNullOrEmpty(session)) {
            return RedirectToAction("Index", new { fileId }); // not authorized
        }

        // 1. The signer's certificate, from CloudHub
        var certificate = PKCertificate.Decode(await cloudhub.GetCertificateAsync(session));
        if (DigitsOnly(certificate.PkiBrazil.CPF) != (string)TempData["cloudhubCpf"]) {
            return StatusCode(403); // someone else signed in at the provider
        }

        // 2. PKI SDK starts the signature. It throws a ValidationException
        //    when the certificate is not trusted.
        var signer = new PadesSigner();
        signer.SetPolicy(PadesPoliciesForGeneration.GetPadesBasic(TrustArbitrators.PkiBrazil));
        signer.SetPdfToSign(GetPdfPath(fileId));
        signer.SetSigningCertificate(certificate);
        var toSignBytes = signer.GetToSignBytes(out var signatureAlgorithm, out var transferData);

        // 3. CloudHub signs the hash with the user's key
        var signature = await cloudhub.SignHashAsync(new SignHashRequest {
            Session = session,
            Hash = signatureAlgorithm.DigestAlgorithm.ComputeHash(toSignBytes),
            DigestAlgorithmOid = signatureAlgorithm.DigestAlgorithm.Oid,
        });

        // 4. PKI SDK finishes the signed PDF
        signer.SetPreComputedSignature(signature, transferData);
        var signedPdf = signer.GetPadesSignature();

        return File(signedPdf, "application/pdf", "signed.pdf");
    }

    private static string DigitsOnly(string value) => Regex.Replace(value ?? "", @"\D", "");
}
```

With PKI SDK, the certificate is known before anything is signed, so the CPF check comes first.
`GetPdfPath` stands for your own storage.

**PHP**

```php title="discover.php"
<?php
require __DIR__ . '/../vendor/autoload.php';

use Lacuna\Cloudhub\CloudhubClient;
use Lacuna\Cloudhub\SessionCreateRequest;
use Lacuna\Cloudhub\TrustServiceSessionTypes;

session_start();

$cloudhub = new CloudhubClient('https://cloudhub.lacunasoftware.com/', getenv('CLOUDHUB_API_KEY'));

$fileId = $_GET['fileId'];
$cpf = preg_replace('/\D/', '', $_POST['cpf']);
// Remember whose signature this is, to check the certificate later
$_SESSION['cloudhubCpf'] = $cpf;

$session = $cloudhub->createSessionAsync(new SessionCreateRequest(
    $cpf,
    'https://app.example.com/cloud-signature/complete.php?fileId=' . urlencode($fileId),
    TrustServiceSessionTypes::SingleSignature
));
?>
<?php if (empty($session->services)) { ?>
    <p>No provider holds a certificate for this CPF.</p>
<?php } ?>
<?php foreach ($session->services as $service) { ?>
    <a href="<?= htmlspecialchars($service->authUrl) ?>">
        Sign with <?= htmlspecialchars($service->serviceInfo->provider) ?>
    </a>
<?php } ?>
```

```php title="complete.php"
<?php
require __DIR__ . '/../vendor/autoload.php';

use Lacuna\Cloudhub\CloudhubClient;
use Lacuna\Cloudhub\SignHashRequest;
use Lacuna\RestPki\PadesSignatureFinisher2;
use Lacuna\RestPki\PadesSignatureStarter;
use Lacuna\RestPki\RestPkiClient;
use Lacuna\RestPki\StandardSecurityContexts;
use Lacuna\RestPki\StandardSignaturePolicies;

session_start();

$cloudhub = new CloudhubClient('https://cloudhub.lacunasoftware.com/', getenv('CLOUDHUB_API_KEY'));
$restPki = new RestPkiClient('https://pki.rest/', getenv('RESTPKI_ACCESS_TOKEN'));

$fileId = $_GET['fileId'];
$session = $_GET['session'] ?? null;
if (empty($session)) {
    header('Location: /cloud-signature/index.php?fileId=' . urlencode($fileId)); // not authorized
    exit;
}

// 1. The signer's certificate, from CloudHub (base64)
$certificate = $cloudhub->getCertificateAsync($session);

// 2. Rest PKI starts the signature
$starter = new PadesSignatureStarter($restPki);
$starter->setSignerCertificateBase64($certificate);
$starter->setPdfToSignFromPath(getPdfPath($fileId));
$starter->signaturePolicy = StandardSignaturePolicies::PADES_BASIC;
$starter->securityContext = StandardSecurityContexts::PKI_BRAZIL;
$start = $starter->start();

// 3. CloudHub signs the hash with the user's key. Rest PKI's PHP client
//    returns the hash as raw bytes, and CloudHub takes it base64-encoded.
$signature = $cloudhub->signHashAsync(new SignHashRequest(
    $session,
    base64_encode($start->toSignHash),
    null,
    $start->digestAlgorithmOid
));

// 4. Rest PKI finishes the signed PDF
$finisher = new PadesSignatureFinisher2($restPki);
$finisher->token = $start->token;
$finisher->signatureBase64 = $signature;
$result = $finisher->finish();

if ($result->certificate->pkiBrazil->cpf !== $_SESSION['cloudhubCpf']) {
    http_response_code(403); // someone else signed in at the provider: discard the result
    exit;
}
$result->writeToFile(getSignedPdfPath($fileId));
```

`getPdfPath` and `getSignedPdfPath` stand for your own storage.

**Python**

```python title="cloud_signature.py"
import os

from flask import Blueprint, abort, redirect, render_template, request, url_for
from flask import session as web_session
from pkiexpress import PadesSignatureStarter, SignatureFinisher, standard_signature_policies

from cloudhub import call_cloudhub  # the helper from "Get started"

blueprint = Blueprint('cloud_signature', __name__, url_prefix='/cloud-signature')


@blueprint.route('/discover/<file_id>', methods=['POST'])
def discover(file_id):
    cpf = ''.join(c for c in request.form['cpf'] if c.isdigit())
    # Remember whose signature this is, to check the certificate later
    web_session['cloudhub_cpf'] = cpf

    session = call_cloudhub('POST', '/api/sessions', json={
        'identifier': cpf,
        'redirectUri': url_for('.complete', file_id=file_id, _external=True),
        'type': 'SingleSignature',
    })

    # The template links each entry of services to its authUrl
    return render_template('cloud_signature/discover.html', services=session['services'])


@blueprint.route('/complete/<file_id>')
def complete(file_id):
    cloudhub_session = request.args.get('session')
    if not cloudhub_session:
        return redirect(url_for('.index', file_id=file_id))  # not authorized

    # 1. The signer's certificate, from CloudHub (base64)
    certificate = call_cloudhub('GET', '/api/sessions/certificate',
                                params={'session': cloudhub_session})

    # 2. PKI Express starts the signature
    pdf_path = get_pdf_path(file_id)
    starter = PadesSignatureStarter()
    starter.signature_policy = standard_signature_policies.PADES_BASIC
    starter.set_pdf_to_sign_from_path(pdf_path)
    starter.set_certificate_from_base64(certificate)
    start = starter.start()

    # 3. CloudHub signs the hash with the user's key
    signature = call_cloudhub('POST', '/api/sessions/sign-hash', json={
        'session': cloudhub_session,
        'hash': start['toSignHash'],
        'digestAlgorithmOid': start['digestAlgorithmOid'],
    })

    # 4. PKI Express finishes the signed PDF
    finisher = SignatureFinisher()
    finisher.set_file_to_sign_from_path(pdf_path)
    finisher.set_transfer_file_from_path(start['transferFile'])
    finisher.signature = signature
    finisher.output_file = get_signed_pdf_path(file_id)
    signer_cert = finisher.complete(get_cert=True)

    if signer_cert.pki_brazil.cpf != web_session.get('cloudhub_cpf'):
        os.remove(finisher.output_file)  # someone else signed in at the provider
        abort(403)

    return render_template('cloud_signature/complete.html', signer_cert=signer_cert)
```

The CloudHub value is named `cloudhub_session` so that it doesn't shadow Flask's `session`.
`get_pdf_path` and `get_signed_pdf_path` stand for your own storage.

**Node.js**

```js title="routes/cloud-signature.js"
const express = require('express');
const { CloudHubClient, TrustServiceSessionTypes } = require('cloudhub-client');
const {
  RestPkiClient,
  PadesSignatureStarter,
  PadesSignatureFinisher,
  StandardSecurityContexts,
  StandardSignaturePolicies,
} = require('restpki-client');

const cloudhub = new CloudHubClient('https://cloudhub.lacunasoftware.com/', process.env.CLOUDHUB_API_KEY);
const restPki = new RestPkiClient('https://pki.rest/', process.env.RESTPKI_ACCESS_TOKEN);
const router = express.Router();

// POST /cloud-signature/discover?fileId=…: the form with the signer's CPF
router.post('/discover', async (req, res, next) => {
  try {
    const { fileId } = req.query;
    const cpf = req.body.cpf.replace(/\D/g, '');
    // Remember whose signature this is, to check the certificate later (express-session)
    req.session.cloudhubCpf = cpf;

    const session = await cloudhub.createSessionAsync({
      identifier: cpf,
      redirectUri: `https://app.example.com/cloud-signature/complete?fileId=${encodeURIComponent(fileId)}`,
      type: TrustServiceSessionTypes.SingleSignature,
    });

    // The view links each entry of services to its authUrl
    res.render('cloud-signature/discover', { services: session.services || [] });
  } catch (err) {
    next(err);
  }
});

// GET /cloud-signature/complete?fileId=…&session=…: the redirect URI
router.get('/complete', async (req, res, next) => {
  try {
    const { fileId, session } = req.query;
    if (!session) {
      return res.redirect(`/cloud-signature?fileId=${encodeURIComponent(fileId)}`); // not authorized
    }

    // 1. The signer's certificate, from CloudHub (base64)
    const certificate = await cloudhub.getCertificateAsync(session);

    // 2. Rest PKI starts the signature. It base64-encodes the certificate
    //    itself, so give it the decoded bytes.
    const starter = new PadesSignatureStarter(restPki);
    starter.setPdfToSignFromPath(getPdfPath(fileId));
    starter.signerCertificate = Buffer.from(certificate, 'base64');
    starter.signaturePolicy = StandardSignaturePolicies.PADES_BASIC;
    starter.securityContext = StandardSecurityContexts.PKI_BRAZIL;
    const start = await starter.start();

    // 3. CloudHub signs the hash with the user's key
    const signature = await cloudhub.signHashAsync({
      session,
      hash: start.toSignHash,
      digestAlgorithmOid: start.digestAlgorithmOid,
    });

    // 4. Rest PKI finishes the signed PDF
    const finisher = new PadesSignatureFinisher(restPki);
    finisher.token = start.token;
    finisher.signature = signature;
    const result = await finisher.finish();

    if (result.certificate.pkiBrazil.cpf !== req.session.cloudhubCpf) {
      return res.sendStatus(403); // someone else signed in at the provider: discard the result
    }
    await result.writeToFile(getSignedPdfPath(fileId));
    res.render('cloud-signature/complete', { signerCert: result.certificate });
  } catch (err) {
    next(err);
  }
});

module.exports = router;
```

`getPdfPath` and `getSignedPdfPath` stand for your own storage.

**Java**

```java title="CloudSignatureController.java"
import cloudhub.SessionsApi;
import cloudhub.client.CloudhubClient;
import cloudhub.client.model.SessionCreateRequest;
import cloudhub.client.model.SessionModel;
import cloudhub.client.model.SignHashRequest;
import cloudhub.client.model.TrustServiceSessionTypes;
import com.lacunasoftware.restpki.PKCertificate;
import com.lacunasoftware.restpki.PadesSignatureFinisher2;
import com.lacunasoftware.restpki.PadesSignatureStarter2;
import com.lacunasoftware.restpki.RestPkiClient;
import com.lacunasoftware.restpki.SecurityContext;
import com.lacunasoftware.restpki.SignaturePolicy;
import com.lacunasoftware.restpki.SignatureResult;
import com.lacunasoftware.restpki.SignatureStartResult;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

import javax.servlet.http.HttpSession; // jakarta.servlet on Spring Boot 3
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.Base64;
import java.util.Objects;

@Controller
@RequestMapping("/cloud-signature")
public class CloudSignatureController {

    private final SessionsApi cloudhub = new SessionsApi(
        new CloudhubClient("https://cloudhub.lacunasoftware.com", System.getenv("CLOUDHUB_API_KEY")));

    private final RestPkiClient restPki = new RestPkiClient(
        "https://pki.rest/", System.getenv("RESTPKI_ACCESS_TOKEN"));

    // POST /cloud-signature/discover: the form with the signer's CPF
    @PostMapping("/discover")
    public String discover(@RequestParam String fileId, @RequestParam String cpf,
                           HttpSession httpSession, Model model) throws Exception {
        String plainCpf = cpf.replaceAll("\\D", "");
        // Remember whose signature this is, to check the certificate later
        httpSession.setAttribute("cloudhubCpf", plainCpf);

        SessionCreateRequest request = new SessionCreateRequest();
        request.setIdentifier(plainCpf);
        request.setRedirectUri("https://app.example.com/cloud-signature/complete?fileId="
            + URLEncoder.encode(fileId, "UTF-8"));
        request.setType(TrustServiceSessionTypes.SINGLE_SIGNATURE);
        SessionModel session = cloudhub.apiSessionsPost(request);

        // The template links each entry of services to its authUrl
        model.addAttribute("services", session.getServices());
        return "cloud-signature/discover";
    }

    // GET /cloud-signature/complete?fileId=…&session=…: the redirect URI
    @GetMapping("/complete")
    public String complete(@RequestParam String fileId,
                           @RequestParam(required = false) String session,
                           HttpSession httpSession, Model model) throws Exception {
        if (session == null || session.isEmpty()) {
            return "redirect:/cloud-signature?fileId=" + fileId; // not authorized
        }

        // 1. The signer's certificate, from CloudHub (base64)
        String certificate = unquote(cloudhub.apiSessionsCertificateGet(session));

        // 2. Rest PKI starts the signature
        PadesSignatureStarter2 starter = new PadesSignatureStarter2(restPki);
        starter.setPdfToSign(getPdfPath(fileId));
        starter.setSignerCertificateBase64(certificate);
        starter.setSignaturePolicy(SignaturePolicy.PadesBasic);
        starter.setSecurityContext(SecurityContext.pkiBrazil);
        SignatureStartResult start = starter.start();

        // 3. CloudHub signs the hash with the user's key
        SignHashRequest signRequest = new SignHashRequest();
        signRequest.setSession(session);
        signRequest.setHash(start.getToSignHashRaw());
        signRequest.setDigestAlgorithmOid(start.getDigestAlgorithmOid());
        byte[] signature = Base64.getDecoder().decode(unquote(cloudhub.apiSessionsSignHashPost(signRequest)));

        // 4. Rest PKI finishes the signed PDF
        PadesSignatureFinisher2 finisher = new PadesSignatureFinisher2(restPki);
        finisher.setToken(start.getToken());
        finisher.setSignature(signature);
        SignatureResult result = finisher.finish();

        PKCertificate signerCert = result.getCertificate();
        if (!Objects.equals(signerCert.getPkiBrazil().getCpf(), httpSession.getAttribute("cloudhubCpf"))) {
            // Someone else signed in at the provider: discard the result
            throw new ResponseStatusException(HttpStatus.FORBIDDEN);
        }
        try (InputStream signedPdf = result.openRead()) {
            Files.copy(signedPdf, getSignedPdfPath(fileId), StandardCopyOption.REPLACE_EXISTING);
        }

        model.addAttribute("signerCert", signerCert);
        return "cloud-signature/complete";
    }

    // The certificate and sign-hash operations answer with a JSON string ("MIIG..."),
    // and the client hands back its raw bytes, quotes included
    private static String unquote(byte[] body) {
        return new String(body, StandardCharsets.UTF_8).replace("\"", "");
    }
}
```

`getPdfPath` and `getSignedPdfPath` stand for your own storage and return a `java.nio.file.Path`.

The examples trust ICP-Brasil certificates only (`TrustArbitrators.PkiBrazil`,
`PKI_BRAZIL`, `SecurityContext.pkiBrazil`). To test with a provider's test certificates, trust their
root in development only; the samples show how for each library.

## Variations

- **A visible signature stamp.** Add a visual representation through your signature library, exactly
  as with any other certificate. The samples linked above all add one.
- **Several documents with one authorization.** Create the session with
  `TrustServiceSessionTypes.SignatureSession` and a `lifetimeInSeconds` long enough for the batch,
  then repeat steps 2 to 4 for each document with the same session. See
  [Session types](https://docs.lacunasoftware.com/en-us/articles/cloudhub/how-it-works#session-types).
- **CAdES and XAdES.** The flow is the same with your signature library's CAdES or XML starter. The
  PHP samples include
  [an XML signature with CloudHub](https://github.com/LacunaSoftware/PkiSuiteSamples/tree/master/php/plain/public/xml-signature-cloudhub-rest).

**Demos and samples**

- Live demo: [Sign a PDF with your cloud certificate](https://try.lacunasoftware.com/en/pki-suite/sign-with-cloud-certificate)
- Sign a PDF with a cloud certificate using CloudHub: [.NET](https://github.com/LacunaSoftware/PkiSuiteSamples/blob/master/dotnet/mvc/PkiSuiteAspNetMvcSample/Controllers/PadesCloudHubSdkController.cs) · [Java](https://github.com/LacunaSoftware/PkiSuiteSamples/blob/master/java/springmvc/src/main/java/com/lacunasoftware/pkisuite/controller/PadesSignatureCloudhubRestController.java) · [Node.js](https://github.com/LacunaSoftware/PkiSuiteSamples/blob/master/nodejs/expressmvc/routes/pades-signature-cloudhub-restpki.js) · [PHP](https://github.com/LacunaSoftware/PkiSuiteSamples/blob/master/php/plain/public/pades-signature-cloudhub-rest/index.php)
- Sign an XML document with a cloud certificate using CloudHub: [PHP](https://github.com/LacunaSoftware/PkiSuiteSamples/blob/master/php/plain/public/xml-signature-cloudhub-rest/index.php)
