Firmas digitales PDF en ASP.NET Core para España: PAdES, XAdES, TicketBAI y LOPDGDD con IronPDF
Firmas digitales PDF en ASP.NET Core para España: PAdES, XAdES, TicketBAI y LOPDGDD con IronPDF
El Royal Decree-Law 15/2025 obliga al software de facturación español a implementar VeriFactu antes del 1 de enero de 2027. Las haciendas forales del País Vasco (Bizkaia, Gipuzkoa, Araba) ya exigen TicketBAI con firma XAdES sobre cada XML de factura. La LOPDGDD exige que los documentos archivados mantengan su integridad verificable durante el periodo de retención. Para los ISVs que desarrollan software de facturación con ASP.NET Core, estas obligaciones convergen en un único requisito técnico: firma digital PAdES de los PDFs de factura con certificado FNMT.
Esta guía cubre la implementación completa en C#: desde la distinción entre PAdES (para el PDF) y XAdES (para el XML TicketBAI), hasta los patrones de producción para gestionar certificados FNMT, firmar en lote para el SII, y verificar la integridad del archivo conforme a eIDAS.
Por qué VeriFactu y TicketBAI cambian los requisitos de firma de su software
Antes de VeriFactu y TicketBAI, la firma digital en facturas españolas era una buena práctica. Con VeriFactu, pasa a ser una expectativa normativa: los PDFs de factura que incluyan la leyenda VERI*FACTU y el código QR de la AEAT son el documento de presentación de una transacción registrada en la cadena de custodia fiscal. La firma PAdES sobre ese PDF es la garantía de que no fue alterado tras el registro.
En el País Vasco, TicketBAI va más lejos. Cada factura requiere:
- Generación del XML TicketBAI con los datos del emisor, receptor e importes
- Firma del XML con XAdES (XML Advanced Electronic Signatures) usando certificado reconocido por la hacienda foral
- Envío del XML firmado a la hacienda correspondiente:
bizkaia.eus(también programa BATUZ),gipuzkoa.eusoaraba.eus - Generación del PDF de presentación con el identificador TicketBAI devuelto por la hacienda
- Firma PAdES del PDF con IronPDF para archivado conforme a LOPDGDD y eIDAS
La firma XAdES del XML (paso 2) corresponde a bibliotecas de firma XML especializadas. IronPDF gestiona exclusivamente la firma PAdES del PDF de presentación (paso 5).
El marco normativo que determina qué tipo de firma usar
| Marco | Estándar de firma | Aplica sobre | Quién lo requiere |
|---|---|---|---|
| eIDAS (Reglamento UE) | PAdES / XAdES / CAdES | PDF / XML / cualquier fichero | Cualquier documento con validez jurídica en la UE |
| LOPDGDD | PAdES-LTV | PDFs archivados con datos personales | AEPD, auditorías de cumplimiento |
| VeriFactu | PAdES (PDF), sin requisito explícito de firma en el PDF | PDF de factura con QR AEAT y leyenda VERI*FACTU |
AEAT, software vendido en territorio AEAT |
| TicketBAI | XAdES (XML) + PAdES opcional en PDF | XML TicketBAI; PDF de presentación | Bizkaia, Gipuzkoa y Araba (haciendas forales) |
| Facturae / FACe | CAdES o XAdES (sobre el XML Facturae) | XML Facturae 3.2.2 | AAPP que reciben facturas B2G |
Para la mayoría de los ISVs con clientes en territorio AEAT y País Vasco, el requisito práctico es: PAdES en el PDF con certificado FNMT.
Los niveles de PAdES y cuándo usar cada uno
PAdES-B cubre la firma básica. Válida para contratos y documentos inmediatos, pero no para archivado a largo plazo.
PAdES-T añade sello de tiempo (timestamp). Recomendable para facturas electrónicas que deben demostrar cuándo existió la firma.
PAdES-LTV incorpora datos de validación a largo plazo: respuestas OCSP, CRLs y cadena de certificados completa. Es el nivel recomendado para archivado bajo LOPDGDD: permite verificar la firma décadas después, incluso cuando el certificado FNMT original haya caducado.
Instalación de IronPDF en un proyecto ASP.NET Core
Install-Package IronPdf
dotnet add package IronPdf
Install-Package IronPdf
dotnet add package IronPdf
using IronPdf;
using IronPdf.Signing;
using IronPdf;
using IronPdf.Signing;
Imports IronPdf
Imports IronPdf.Signing
Configure la clave de licencia al inicio de la aplicación en Program.cs:
var builder = WebApplication.CreateBuilder(args);
// Clave de licencia desde configuración (no hardcodeada en código)
IronPdf.License.LicenseKey = builder.Configuration["IronPdf:LicenseKey"]
?? throw new InvalidOperationException("IronPDF license key not configured");
var app = builder.Build();
var builder = WebApplication.CreateBuilder(args);
// Clave de licencia desde configuración (no hardcodeada en código)
IronPdf.License.LicenseKey = builder.Configuration["IronPdf:LicenseKey"]
?? throw new InvalidOperationException("IronPDF license key not configured");
var app = builder.Build();
Imports Microsoft.AspNetCore.Builder
Imports Microsoft.Extensions.Configuration
Dim builder = WebApplication.CreateBuilder(args)
' Clave de licencia desde configuración (no hardcodeada en código)
IronPdf.License.LicenseKey = If(builder.Configuration("IronPdf:LicenseKey"), Throw New InvalidOperationException("IronPDF license key not configured"))
Dim app = builder.Build()
IronPDF es compatible con .NET 8 LTS y .NET 9/10, adaptándose a los proyectos ASP.NET Core modernos.
Firma PAdES de facturas en ASP.NET Core: flujo VeriFactu
El caso de uso más habitual para ISVs españoles es generar un PDF de factura con IronPDF y firmarlo con PAdES antes de enviarlo al cliente o archivarlo. El siguiente ejemplo genera un PDF desde HTML e incluye la leyenda VERI*FACTU y la firma PAdES:
using IronPdf;
using IronPdf.Signing;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPost("/factura/firmar", (IWebHostEnvironment env) =>
{
// Generar el PDF de la factura desde plantilla HTML
// La plantilla incluye leyenda VERI*FACTU y QR AEAT
var renderer = new ChromePdfRenderer();
renderer.RenderingOptions.PaperSize = IronPdf.Rendering.PdfPaperSize.A4;
var documento = renderer.RenderHtmlAsPdf(@"
<html lang='es'>
<head><meta charset='UTF-8'></head>
<body>
<div style='display:flex; justify-content:space-between;'>
<div>
<h2>Factura Electrónica F2024-001</h2>
<p>NIF: B12345678 | Fecha: 01/12/2024</p>
</div>
<div style='text-align:right;'>
<img src='data:image/png;base64,{QR_AEAT_BASE64}'
style='width:80px;height:80px;' alt='QR AEAT' />
<p style='font-weight:bold; font-size:9pt;'>VERI*FACTU</p>
<p style='font-size:7pt;'>Factura verificable en la sede electrónica de la AEAT</p>
</div>
</div>
<p>Total: 1.210,00 € (IVA incluido)</p>
</body>
</html>");
// Ruta del certificado FNMT en el servidor
// En producción: cargar desde variable de entorno, no desde sistema de ficheros
string certPath = Path.Combine(env.ContentRootPath, "Certificates", "sello_empresa_fnmt.pfx");
// Firma PAdES conforme a eIDAS para archivado bajo LOPDGDD
var firma = new PdfSignature(certPath, "contraseñaCertificado")
{
SigningContact = "facturacion@miempresa.com",
SigningLocation = "Madrid, España",
SigningReason = "Factura electrónica — VeriFactu/eIDAS/LOPDGDD"
};
documento.Sign(firma);
string rutaSalida = Path.Combine(Path.GetTempPath(), "factura_firmada.pdf");
documento.SaveAs(rutaSalida);
return Results.File(rutaSalida, "application/pdf", "factura_firmada.pdf");
});
app.Run();
using IronPdf;
using IronPdf.Signing;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPost("/factura/firmar", (IWebHostEnvironment env) =>
{
// Generar el PDF de la factura desde plantilla HTML
// La plantilla incluye leyenda VERI*FACTU y QR AEAT
var renderer = new ChromePdfRenderer();
renderer.RenderingOptions.PaperSize = IronPdf.Rendering.PdfPaperSize.A4;
var documento = renderer.RenderHtmlAsPdf(@"
<html lang='es'>
<head><meta charset='UTF-8'></head>
<body>
<div style='display:flex; justify-content:space-between;'>
<div>
<h2>Factura Electrónica F2024-001</h2>
<p>NIF: B12345678 | Fecha: 01/12/2024</p>
</div>
<div style='text-align:right;'>
<img src='data:image/png;base64,{QR_AEAT_BASE64}'
style='width:80px;height:80px;' alt='QR AEAT' />
<p style='font-weight:bold; font-size:9pt;'>VERI*FACTU</p>
<p style='font-size:7pt;'>Factura verificable en la sede electrónica de la AEAT</p>
</div>
</div>
<p>Total: 1.210,00 € (IVA incluido)</p>
</body>
</html>");
// Ruta del certificado FNMT en el servidor
// En producción: cargar desde variable de entorno, no desde sistema de ficheros
string certPath = Path.Combine(env.ContentRootPath, "Certificates", "sello_empresa_fnmt.pfx");
// Firma PAdES conforme a eIDAS para archivado bajo LOPDGDD
var firma = new PdfSignature(certPath, "contraseñaCertificado")
{
SigningContact = "facturacion@miempresa.com",
SigningLocation = "Madrid, España",
SigningReason = "Factura electrónica — VeriFactu/eIDAS/LOPDGDD"
};
documento.Sign(firma);
string rutaSalida = Path.Combine(Path.GetTempPath(), "factura_firmada.pdf");
documento.SaveAs(rutaSalida);
return Results.File(rutaSalida, "application/pdf", "factura_firmada.pdf");
});
app.Run();
Imports IronPdf
Imports IronPdf.Signing
Imports Microsoft.AspNetCore.Mvc
Dim builder = WebApplication.CreateBuilder(args)
Dim app = builder.Build()
app.MapPost("/factura/firmar", Function(env As IWebHostEnvironment)
' Generar el PDF de la factura desde plantilla HTML
' La plantilla incluye leyenda VERI*FACTU y QR AEAT
Dim renderer = New ChromePdfRenderer()
renderer.RenderingOptions.PaperSize = IronPdf.Rendering.PdfPaperSize.A4
Dim documento = renderer.RenderHtmlAsPdf("
<html lang='es'>
<head><meta charset='UTF-8'></head>
<body>
<div style='display:flex; justify-content:space-between;'>
<div>
<h2>Factura Electrónica F2024-001</h2>
<p>NIF: B12345678 | Fecha: 01/12/2024</p>
</div>
<div style='text-align:right;'>
<img src='data:image/png;base64,{QR_AEAT_BASE64}'
style='width:80px;height:80px;' alt='QR AEAT' />
<p style='font-weight:bold; font-size:9pt;'>VERI*FACTU</p>
<p style='font-size:7pt;'>Factura verificable en la sede electrónica de la AEAT</p>
</div>
</div>
<p>Total: 1.210,00 € (IVA incluido)</p>
</body>
</html>")
' Ruta del certificado FNMT en el servidor
' En producción: cargar desde variable de entorno, no desde sistema de ficheros
Dim certPath As String = Path.Combine(env.ContentRootPath, "Certificates", "sello_empresa_fnmt.pfx")
' Firma PAdES conforme a eIDAS para archivado bajo LOPDGDD
Dim firma = New PdfSignature(certPath, "contraseñaCertificado") With {
.SigningContact = "facturacion@miempresa.com",
.SigningLocation = "Madrid, España",
.SigningReason = "Factura electrónica — VeriFactu/eIDAS/LOPDGDD"
}
documento.Sign(firma)
Dim rutaSalida As String = Path.Combine(Path.GetTempPath(), "factura_firmada.pdf")
documento.SaveAs(rutaSalida)
Return Results.File(rutaSalida, "application/pdf", "factura_firmada.pdf")
End Function)
app.Run()
ChromePdfRenderer convierte cualquier plantilla HTML válida a PDF con texto real seleccionable — esencial para que los sistemas de la AEAT puedan procesar el contenido. PdfSignature acepta la ruta del certificado FNMT y la contraseña; las propiedades opcionales (SigningContact, SigningLocation, SigningReason) añaden metadatos que los visores PDF muestran en el panel de firmas.
Devolver el PDF firmado desde memoria (sin fichero temporal)
Para APIs de alto rendimiento donde no se desea escribir ficheros temporales:
var stream = new MemoryStream();
documento.SaveAs(stream);
stream.Position = 0;
return Results.File(stream, "application/pdf", "factura_firmada.pdf");
var stream = new MemoryStream();
documento.SaveAs(stream);
stream.Position = 0;
return Results.File(stream, "application/pdf", "factura_firmada.pdf");
Dim stream As New MemoryStream()
documento.SaveAs(stream)
stream.Position = 0
Return Results.File(stream, "application/pdf", "factura_firmada.pdf")
Firma para TicketBAI: tres variantes forales en ASP.NET Core
TicketBAI exige gestionar las tres diputaciones forales del País Vasco de forma independiente. Cada una tiene su propia especificación de implementación y su propio portal de registro. El siguiente servicio ASP.NET Core contempla las tres variantes:
using IronPdf;
using IronPdf.Signing;
using Microsoft.AspNetCore.Mvc;
public class ServicioTicketBaiPdf
{
private readonly string _rutaCertificado;
private readonly string _passwordCertificado;
public ServicioTicketBaiPdf(IConfiguration config)
{
// Cargar certificado desde configuración segura (no fichero en producción)
_rutaCertificado = config["TicketBAI:CertPath"]!;
_passwordCertificado = config["TicketBAI:CertPassword"]!;
}
// Genera y firma el PDF de presentación de una factura TicketBAI
// El XML TicketBAI ya debe estar firmado con XAdES y registrado en la hacienda foral
public byte[] GenerarPdfFacturaTicketBai(
string htmlFactura,
string provinciaForal, // "Bizkaia", "Gipuzkoa" o "Araba"
string identificadorTbai) // ID asignado por la hacienda foral al registro
{
IronPdf.License.LicenseKey = "YOUR-LICENSE-KEY";
// Añadir el identificador TicketBAI al pie de la factura
string htmlConTbai = htmlFactura
+ $"<div class='pie-ticketbai'>" +
$"<p>TicketBAI: {identificadorTbai} | " +
$"Hacienda Foral: {ObtenerHaciendaForal(provinciaForal)}</p>" +
"</div>";
var renderer = new ChromePdfRenderer();
renderer.RenderingOptions.PaperSize = IronPdf.Rendering.PdfPaperSize.A4;
var pdf = renderer.RenderHtmlAsPdf(htmlConTbai);
// Firma PAdES — configura el location con la provincia foral
var firma = new PdfSignature(_rutaCertificado, _passwordCertificado)
{
SigningLocation = $"País Vasco — {provinciaForal}, España",
SigningReason = $"Factura TicketBAI {provinciaForal} — conforme eIDAS"
};
pdf.Sign(firma);
return pdf.BinaryData;
}
private static string ObtenerHaciendaForal(string provincia) => provincia switch
{
"Bizkaia" => "Diputación Foral de Bizkaia (bizkaia.eus)",
"Gipuzkoa" => "Diputación Foral de Gipuzkoa (gipuzkoa.eus)",
"Araba" => "Diputación Foral de Araba (araba.eus)",
_ => throw new ArgumentException($"Provincia foral desconocida: {provincia}")
};
}
using IronPdf;
using IronPdf.Signing;
using Microsoft.AspNetCore.Mvc;
public class ServicioTicketBaiPdf
{
private readonly string _rutaCertificado;
private readonly string _passwordCertificado;
public ServicioTicketBaiPdf(IConfiguration config)
{
// Cargar certificado desde configuración segura (no fichero en producción)
_rutaCertificado = config["TicketBAI:CertPath"]!;
_passwordCertificado = config["TicketBAI:CertPassword"]!;
}
// Genera y firma el PDF de presentación de una factura TicketBAI
// El XML TicketBAI ya debe estar firmado con XAdES y registrado en la hacienda foral
public byte[] GenerarPdfFacturaTicketBai(
string htmlFactura,
string provinciaForal, // "Bizkaia", "Gipuzkoa" o "Araba"
string identificadorTbai) // ID asignado por la hacienda foral al registro
{
IronPdf.License.LicenseKey = "YOUR-LICENSE-KEY";
// Añadir el identificador TicketBAI al pie de la factura
string htmlConTbai = htmlFactura
+ $"<div class='pie-ticketbai'>" +
$"<p>TicketBAI: {identificadorTbai} | " +
$"Hacienda Foral: {ObtenerHaciendaForal(provinciaForal)}</p>" +
"</div>";
var renderer = new ChromePdfRenderer();
renderer.RenderingOptions.PaperSize = IronPdf.Rendering.PdfPaperSize.A4;
var pdf = renderer.RenderHtmlAsPdf(htmlConTbai);
// Firma PAdES — configura el location con la provincia foral
var firma = new PdfSignature(_rutaCertificado, _passwordCertificado)
{
SigningLocation = $"País Vasco — {provinciaForal}, España",
SigningReason = $"Factura TicketBAI {provinciaForal} — conforme eIDAS"
};
pdf.Sign(firma);
return pdf.BinaryData;
}
private static string ObtenerHaciendaForal(string provincia) => provincia switch
{
"Bizkaia" => "Diputación Foral de Bizkaia (bizkaia.eus)",
"Gipuzkoa" => "Diputación Foral de Gipuzkoa (gipuzkoa.eus)",
"Araba" => "Diputación Foral de Araba (araba.eus)",
_ => throw new ArgumentException($"Provincia foral desconocida: {provincia}")
};
}
Imports IronPdf
Imports IronPdf.Signing
Imports Microsoft.AspNetCore.Mvc
Public Class ServicioTicketBaiPdf
Private ReadOnly _rutaCertificado As String
Private ReadOnly _passwordCertificado As String
Public Sub New(config As IConfiguration)
' Cargar certificado desde configuración segura (no fichero en producción)
_rutaCertificado = config("TicketBAI:CertPath")
_passwordCertificado = config("TicketBAI:CertPassword")
End Sub
' Genera y firma el PDF de presentación de una factura TicketBAI
' El XML TicketBAI ya debe estar firmado con XAdES y registrado en la hacienda foral
Public Function GenerarPdfFacturaTicketBai(
htmlFactura As String,
provinciaForal As String, ' "Bizkaia", "Gipuzkoa" o "Araba"
identificadorTbai As String) As Byte() ' ID asignado por la hacienda foral al registro
IronPdf.License.LicenseKey = "YOUR-LICENSE-KEY"
' Añadir el identificador TicketBAI al pie de la factura
Dim htmlConTbai As String = htmlFactura _
& $"<div class='pie-ticketbai'>" _
& $"<p>TicketBAI: {identificadorTbai} | " _
& $"Hacienda Foral: {ObtenerHaciendaForal(provinciaForal)}</p>" _
& "</div>"
Dim renderer As New ChromePdfRenderer()
renderer.RenderingOptions.PaperSize = IronPdf.Rendering.PdfPaperSize.A4
Dim pdf = renderer.RenderHtmlAsPdf(htmlConTbai)
' Firma PAdES — configura el location con la provincia foral
Dim firma As New PdfSignature(_rutaCertificado, _passwordCertificado) With {
.SigningLocation = $"País Vasco — {provinciaForal}, España",
.SigningReason = $"Factura TicketBAI {provinciaForal} — conforme eIDAS"
}
pdf.Sign(firma)
Return pdf.BinaryData
End Function
Private Shared Function ObtenerHaciendaForal(provincia As String) As String
Select Case provincia
Case "Bizkaia"
Return "Diputación Foral de Bizkaia (bizkaia.eus)"
Case "Gipuzkoa"
Return "Diputación Foral de Gipuzkoa (gipuzkoa.eus)"
Case "Araba"
Return "Diputación Foral de Araba (araba.eus)"
Case Else
Throw New ArgumentException($"Provincia foral desconocida: {provincia}")
End Select
End Function
End Class
Gestión del certificado FNMT en producción
Para software de facturación certificado bajo VeriFactu o TicketBAI, la gestión segura del certificado FNMT es crítica:
Variables de entorno (recomendado para la mayoría de los proyectos)
string certBase64 = Environment.GetEnvironmentVariable("FACTURACION_CERT_PFX")
?? throw new InvalidOperationException("Certificado FNMT no configurado.");
byte[] certBytes = Convert.FromBase64String(certBase64);
string certPassword = Environment.GetEnvironmentVariable("FACTURACION_CERT_PASSWORD")
?? throw new InvalidOperationException("Contraseña del certificado no configurada.");
var firma = new PdfSignature(certBytes, certPassword)
{
SigningReason = "Factura electrónica — certificado FNMT/AEAT"
};
string certBase64 = Environment.GetEnvironmentVariable("FACTURACION_CERT_PFX")
?? throw new InvalidOperationException("Certificado FNMT no configurado.");
byte[] certBytes = Convert.FromBase64String(certBase64);
string certPassword = Environment.GetEnvironmentVariable("FACTURACION_CERT_PASSWORD")
?? throw new InvalidOperationException("Contraseña del certificado no configurada.");
var firma = new PdfSignature(certBytes, certPassword)
{
SigningReason = "Factura electrónica — certificado FNMT/AEAT"
};
Imports System
Dim certBase64 As String = Environment.GetEnvironmentVariable("FACTURACION_CERT_PFX")
If certBase64 Is Nothing Then
Throw New InvalidOperationException("Certificado FNMT no configurado.")
End If
Dim certBytes As Byte() = Convert.FromBase64String(certBase64)
Dim certPassword As String = Environment.GetEnvironmentVariable("FACTURACION_CERT_PASSWORD")
If certPassword Is Nothing Then
Throw New InvalidOperationException("Contraseña del certificado no configurada.")
End If
Dim firma As New PdfSignature(certBytes, certPassword) With {
.SigningReason = "Factura electrónica — certificado FNMT/AEAT"
}
Este patrón mantiene las credenciales fuera del control de versiones y simplifica la rotación del certificado cuando caduca: se actualiza la variable de entorno y se reinicia el servicio.
Azure Key Vault (para proyectos con infraestructura Azure)
Para proyectos en Azure, Azure Key Vault permite almacenar y usar el certificado FNMT sin que la clave privada salga del almacén. El SDK de .NET proporciona CertificateClient para acceder al certificado.
Firma en lote de facturas para VeriFactu y SII
El SII (Suministro Inmediato de Información) requiere que las grandes empresas comuniquen sus libros de registro a la AEAT. Para firmar lotes de facturas en el cierre mensual, cargue el certificado FNMT una sola vez fuera del bucle:
using IronPdf;
using IronPdf.Signing;
app.MapPost("/facturas/firmar-lote", (IWebHostEnvironment env) =>
{
string certPath = Path.Combine(env.ContentRootPath, "Certificates", "sello_empresa_fnmt.pfx");
// Cargar certificado una sola vez — más eficiente para lotes grandes
var firma = new PdfSignature(certPath, "contraseñaCert")
{
SigningReason = "Lote facturas mensual — VeriFactu/SII"
};
string[] rutasFacturas = Directory.GetFiles(
Path.Combine(env.ContentRootPath, "FacturasPendientes"),
"*.pdf"
);
string dirSalida = Path.Combine(env.ContentRootPath, "FacturasFirmadas");
Directory.CreateDirectory(dirSalida);
foreach (string rutaFactura in rutasFacturas)
{
var doc = PdfDocument.FromFile(rutaFactura);
doc.Sign(firma);
doc.SaveAs(Path.Combine(dirSalida, Path.GetFileName(rutaFactura)));
}
return Results.Ok(new { FacturasFirmadas = rutasFacturas.Length });
});
using IronPdf;
using IronPdf.Signing;
app.MapPost("/facturas/firmar-lote", (IWebHostEnvironment env) =>
{
string certPath = Path.Combine(env.ContentRootPath, "Certificates", "sello_empresa_fnmt.pfx");
// Cargar certificado una sola vez — más eficiente para lotes grandes
var firma = new PdfSignature(certPath, "contraseñaCert")
{
SigningReason = "Lote facturas mensual — VeriFactu/SII"
};
string[] rutasFacturas = Directory.GetFiles(
Path.Combine(env.ContentRootPath, "FacturasPendientes"),
"*.pdf"
);
string dirSalida = Path.Combine(env.ContentRootPath, "FacturasFirmadas");
Directory.CreateDirectory(dirSalida);
foreach (string rutaFactura in rutasFacturas)
{
var doc = PdfDocument.FromFile(rutaFactura);
doc.Sign(firma);
doc.SaveAs(Path.Combine(dirSalida, Path.GetFileName(rutaFactura)));
}
return Results.Ok(new { FacturasFirmadas = rutasFacturas.Length });
});
Imports IronPdf
Imports IronPdf.Signing
app.MapPost("/facturas/firmar-lote", Function(env As IWebHostEnvironment)
Dim certPath As String = Path.Combine(env.ContentRootPath, "Certificates", "sello_empresa_fnmt.pfx")
' Cargar certificado una sola vez — más eficiente para lotes grandes
Dim firma As New PdfSignature(certPath, "contraseñaCert") With {
.SigningReason = "Lote facturas mensual — VeriFactu/SII"
}
Dim rutasFacturas As String() = Directory.GetFiles(
Path.Combine(env.ContentRootPath, "FacturasPendientes"),
"*.pdf"
)
Dim dirSalida As String = Path.Combine(env.ContentRootPath, "FacturasFirmadas")
Directory.CreateDirectory(dirSalida)
For Each rutaFactura As String In rutasFacturas
Dim doc = PdfDocument.FromFile(rutaFactura)
doc.Sign(firma)
doc.SaveAs(Path.Combine(dirSalida, Path.GetFileName(rutaFactura)))
Next
Return Results.Ok(New With {.FacturasFirmadas = rutasFacturas.Length})
End Function)
Para lotes muy grandes, las operaciones de firma de IronPDF son seguras para hilos: puede usar Parallel.ForEach si cada instancia de PdfDocument está aislada en su propio hilo.
Verificación programática de firmas digitales
Tras firmar y enviar un documento, es posible que necesite verificar que la firma sigue siendo válida:
app.MapGet("/facturas/verificar/{nombre}", (string nombre, IWebHostEnvironment env) =>
{
string rutaFirmada = Path.Combine(env.ContentRootPath, "FacturasFirmadas", nombre);
var documento = PdfDocument.FromFile(rutaFirmada);
var firmas = documento.GetSignatures();
foreach (var sig in firmas)
{
bool esValida = sig.VerifySignature();
string estado = esValida
? $"Válida — firmada por {sig.SignerName} el {sig.SigningTime:dd/MM/yyyy}"
: "INVÁLIDA — el documento puede haber sido manipulado";
Console.WriteLine(estado);
}
return Results.Ok(new { NumeroFirmas = firmas.Count });
});
app.MapGet("/facturas/verificar/{nombre}", (string nombre, IWebHostEnvironment env) =>
{
string rutaFirmada = Path.Combine(env.ContentRootPath, "FacturasFirmadas", nombre);
var documento = PdfDocument.FromFile(rutaFirmada);
var firmas = documento.GetSignatures();
foreach (var sig in firmas)
{
bool esValida = sig.VerifySignature();
string estado = esValida
? $"Válida — firmada por {sig.SignerName} el {sig.SigningTime:dd/MM/yyyy}"
: "INVÁLIDA — el documento puede haber sido manipulado";
Console.WriteLine(estado);
}
return Results.Ok(new { NumeroFirmas = firmas.Count });
});
Imports Microsoft.AspNetCore.Builder
Imports Microsoft.AspNetCore.Hosting
Imports System.IO
app.MapGet("/facturas/verificar/{nombre}", Function(nombre As String, env As IWebHostEnvironment)
Dim rutaFirmada As String = Path.Combine(env.ContentRootPath, "FacturasFirmadas", nombre)
Dim documento = PdfDocument.FromFile(rutaFirmada)
Dim firmas = documento.GetSignatures()
For Each sig In firmas
Dim esValida As Boolean = sig.VerifySignature()
Dim estado As String = If(esValida,
$"Válida — firmada por {sig.SignerName} el {sig.SigningTime:dd/MM/yyyy}",
"INVÁLIDA — el documento puede haber sido manipulado")
Console.WriteLine(estado)
Next
Return Results.Ok(New With {.NumeroFirmas = firmas.Count})
End Function)
GetSignatures() devuelve todas las firmas digitales del PDF. Cada objeto PdfDigitalSignature expone VerifySignature(), el nombre del firmante, la marca de tiempo y la cadena de certificados — información suficiente para un registro de auditoría conforme a LOPDGDD.
Añadir imagen de firma visible (sello de empresa)
Para facturas y contratos que requieren representación visual de la firma además de la criptográfica — por ejemplo, el sello de la empresa —, IronPDF soporta la composición de la imagen en la página:
using IronPdf;
using IronPdf.Signing;
using IronSoftware.Drawing;
app.MapPost("/facturas/firmar-con-sello", (IWebHostEnvironment env) =>
{
string pdfPath = Path.Combine(env.ContentRootPath, "Documents", "factura.pdf");
var documento = PdfDocument.FromFile(pdfPath);
string certPath = Path.Combine(env.ContentRootPath, "Certificates", "sello_empresa_fnmt.pfx");
string imagePath = Path.Combine(env.ContentRootPath, "Images", "sello_empresa.png");
// Área donde aparece el sello visible (x, y, ancho, alto en puntos)
var areaSello = new Rectangle(50, 680, 200, 80);
var firma = new PdfSignature(certPath, "contraseñaCert");
firma.LoadSignatureImageFromFile(imagePath, pageIndex: 0, areaSello);
documento.Sign(firma);
string rutaSalida = Path.Combine(Path.GetTempPath(), "factura_sellada.pdf");
documento.SaveAs(rutaSalida);
return Results.File(rutaSalida, "application/pdf", "factura_sellada.pdf");
});
using IronPdf;
using IronPdf.Signing;
using IronSoftware.Drawing;
app.MapPost("/facturas/firmar-con-sello", (IWebHostEnvironment env) =>
{
string pdfPath = Path.Combine(env.ContentRootPath, "Documents", "factura.pdf");
var documento = PdfDocument.FromFile(pdfPath);
string certPath = Path.Combine(env.ContentRootPath, "Certificates", "sello_empresa_fnmt.pfx");
string imagePath = Path.Combine(env.ContentRootPath, "Images", "sello_empresa.png");
// Área donde aparece el sello visible (x, y, ancho, alto en puntos)
var areaSello = new Rectangle(50, 680, 200, 80);
var firma = new PdfSignature(certPath, "contraseñaCert");
firma.LoadSignatureImageFromFile(imagePath, pageIndex: 0, areaSello);
documento.Sign(firma);
string rutaSalida = Path.Combine(Path.GetTempPath(), "factura_sellada.pdf");
documento.SaveAs(rutaSalida);
return Results.File(rutaSalida, "application/pdf", "factura_sellada.pdf");
});
Imports IronPdf
Imports IronPdf.Signing
Imports IronSoftware.Drawing
app.MapPost("/facturas/firmar-con-sello", Function(env As IWebHostEnvironment)
Dim pdfPath As String = Path.Combine(env.ContentRootPath, "Documents", "factura.pdf")
Dim documento = PdfDocument.FromFile(pdfPath)
Dim certPath As String = Path.Combine(env.ContentRootPath, "Certificates", "sello_empresa_fnmt.pfx")
Dim imagePath As String = Path.Combine(env.ContentRootPath, "Images", "sello_empresa.png")
' Área donde aparece el sello visible (x, y, ancho, alto en puntos)
Dim areaSello As New Rectangle(50, 680, 200, 80)
Dim firma As New PdfSignature(certPath, "contraseñaCert")
firma.LoadSignatureImageFromFile(imagePath, pageIndex:=0, areaSello)
documento.Sign(firma)
Dim rutaSalida As String = Path.Combine(Path.GetTempPath(), "factura_sellada.pdf")
documento.SaveAs(rutaSalida)
Return Results.File(rutaSalida, "application/pdf", "factura_sellada.pdf")
End Function)
La imagen visible se compone en la página especificada, y la firma criptográfica se aplica simultáneamente a todo el documento.
Campos de formulario de firma para firmantes externos
En flujos donde el documento lo genera su sistema pero debe ser firmado por un tercero (cliente, organismo regulador), puede integrar un campo de formulario de firma dedicado:
using IronPdf;
using IronPdf.Forms;
app.MapGet("/contratos/generar-firmable", (IWebHostEnvironment env) =>
{
var renderer = new ChromePdfRenderer();
var pdf = renderer.RenderHtmlAsPdf(@"
<h1>Contrato de Prestación de Servicios</h1>
<p>Revise los términos y firme en el campo indicado.</p>
");
// Campo de firma con nombre, página, posición y dimensiones en puntos
var campoFirma = new SignatureFormField(
"FirmaCliente",
pageIndex: 0,
x: 50,
y: 600,
width: 300,
height: 100
);
pdf.Form.Add(campoFirma);
string rutaSalida = Path.Combine(Path.GetTempPath(), "contrato_para_firmar.pdf");
pdf.SaveAs(rutaSalida);
return Results.File(rutaSalida, "application/pdf", "contrato_para_firmar.pdf");
});
using IronPdf;
using IronPdf.Forms;
app.MapGet("/contratos/generar-firmable", (IWebHostEnvironment env) =>
{
var renderer = new ChromePdfRenderer();
var pdf = renderer.RenderHtmlAsPdf(@"
<h1>Contrato de Prestación de Servicios</h1>
<p>Revise los términos y firme en el campo indicado.</p>
");
// Campo de firma con nombre, página, posición y dimensiones en puntos
var campoFirma = new SignatureFormField(
"FirmaCliente",
pageIndex: 0,
x: 50,
y: 600,
width: 300,
height: 100
);
pdf.Form.Add(campoFirma);
string rutaSalida = Path.Combine(Path.GetTempPath(), "contrato_para_firmar.pdf");
pdf.SaveAs(rutaSalida);
return Results.File(rutaSalida, "application/pdf", "contrato_para_firmar.pdf");
});
Imports IronPdf
Imports IronPdf.Forms
app.MapGet("/contratos/generar-firmable", Function(env As IWebHostEnvironment)
Dim renderer = New ChromePdfRenderer()
Dim pdf = renderer.RenderHtmlAsPdf("
<h1>Contrato de Prestación de Servicios</h1>
<p>Revise los términos y firme en el campo indicado.</p>
")
' Campo de firma con nombre, página, posición y dimensiones en puntos
Dim campoFirma = New SignatureFormField(
"FirmaCliente",
pageIndex:=0,
x:=50,
y:=600,
width:=300,
height:=100
)
pdf.Form.Add(campoFirma)
Dim rutaSalida As String = Path.Combine(Path.GetTempPath(), "contrato_para_firmar.pdf")
pdf.SaveAs(rutaSalida)
Return Results.File(rutaSalida, "application/pdf", "contrato_para_firmar.pdf")
End Function)
El destinatario puede abrir el PDF en Adobe Acrobat Reader, aplicar su propio certificado o firma electrónica, y devolver el documento firmado a su sistema para su validación y archivo.
Errores frecuentes al firmar PDFs en el ecosistema español
El certificado FNMT no aparece como de confianza en Adobe Acrobat Reader
Por defecto, Adobe Acrobat Reader solo confía en el almacén de certificados de Adobe (AATL). Los certificados FNMT no forman parte de esa lista. Para que la firma aparezca como válida sin advertencias, el receptor debe añadir el certificado raíz de la FNMT al almacén de confianza de su visor, o el emisor debe usar un certificado de una CA incluida en AATL. Para verificación programática mediante VerifySignature() en su propio sistema, esto no es un obstáculo.
Sign() se llama pero el PDF no está firmado al abrir el fichero
document.Sign(firma) prepara la firma pero no la persiste hasta que se llama a SaveAs(). El patrón correcto es siempre Sign() → SaveAs().
La hacienda foral rechaza el XML TicketBAI pero el PDF está correcto
Son dos documentos independientes. El rechazo del XML por la hacienda foral (Bizkaia, Gipuzkoa o Araba) se debe a la firma XAdES o al formato del XML — no a la firma PAdES del PDF. Verifique los esquemas XAdES requeridos en la especificación técnica de cada hacienda foral antes de depurar en IronPDF.
Próximos pasos
La firma digital de PDF en ASP.NET Core para el ecosistema español requiere comprender qué estándar aplica en cada parte del flujo: PAdES para los PDFs de facturas (archivado LOPDGDD/eIDAS), XAdES para los XML TicketBAI (Bizkaia/Gipuzkoa/Araba), y certificados FNMT como la autoridad de confianza reconocida por la AEAT y las haciendas forales.
IronPDF gestiona la capa PAdES para que pueda concentrarse en la lógica de negocio de facturación. Para continuar:
- Cómo generar PDFs desde HTML en ASP.NET Core — base para la mayoría de los flujos de facturación
- Cómo añadir contraseñas y permisos a PDF — combine firma con cifrado
- Cómo fusionar y dividir archivos PDF — ensamblar paquetes de facturas antes de firmar
- Opciones de licencia de IronPDF — licencia comercial propietaria, sin AGPL
Inicie con una prueba gratuita de IronPDF y tenga su primer PDF firmado para el ecosistema español en funcionamiento en menos de una hora. Si tiene preguntas sobre TicketBAI, VeriFactu o LOPDGDD, el equipo de soporte de Iron Software está disponible para ayudarle.
Preguntas Frecuentes
¿Cuál es la diferencia entre PAdES y XAdES en el contexto de la facturación española?
PAdES (PDF Advanced Electronic Signatures) se aplica a documentos PDF — es el estándar bajo eIDAS para firmar PDFs, incluidas las facturas electrónicas archivadas bajo LOPDGDD. XAdES (XML Advanced Electronic Signatures) se aplica a ficheros XML — es el formato de firma requerido para los ficheros TicketBAI que se envían a las haciendas forales del País Vasco (Bizkaia, Gipuzkoa, Araba). IronPDF gestiona la firma PAdES del PDF resultante; la firma XAdES del XML TicketBAI se realiza antes de generar el PDF.
¿Cómo se manejan las tres variantes forales de TicketBAI en ASP.NET Core?
Cada factura para el País Vasco se procesa según la hacienda foral del cliente: Bizkaia (bizkaia.eus, también gestiona el programa BATUZ), Gipuzkoa (gipuzkoa.eus) o Araba (araba.eus). El PDF de presentación se genera tras el registro del XML TicketBAI firmado con XAdES en la hacienda correspondiente. IronPDF firma el PDF con PAdES usando el SigningLocation configurado con la provincia foral.
¿Por qué se recomienda PAdES-LTV para el archivado bajo LOPDGDD?
PAdES-LTV (Long-Term Validation) incluye en el propio PDF todos los datos de validación necesarios (timestamps, estado de revocación OCSP, cadena de certificados) para verificar la firma incluso cuando los certificados originales hayan caducado. Para el archivado a largo plazo de facturas electrónicas conforme a LOPDGDD, esta capacidad de verificación diferida es esencial para demostrar la integridad del documento ante requerimientos fiscales o legales futuros.
¿Cómo se gestiona el certificado FNMT de forma segura en producción?
El fichero .pfx del certificado FNMT nunca debe almacenarse en el sistema de ficheros del servidor ni en el control de versiones. El patrón recomendado para ISVs de facturación es cargar el certificado desde una variable de entorno (base64) en tiempo de ejecución: Environment.GetEnvironmentVariable("FACTURACION_CERT_PFX"). Este patrón permite rotar el certificado cuando caduca sin modificar el código fuente.


