How VeriFactu's SHA-256 Hash Chain Works

VeriFactu requires each invoice registration to include a cryptographic fingerprint of the previous one, forming a tamper-evident chain that AEAT can verify at any time. Here's exactly how that chain is constructed, field by field.

Abstract cryptographic chain visualization
Photo by Dexter Fernandes on Unsplash

Why the chain exists

VeriFactu (Verificación Inmediata de Facturas, Royal Decree 1007/2023) is Spain's real-time invoice registration system, planned to become mandatory in October 2028 (pending regulatory approval). Its goal is to make it structurally impossible to modify or delete an invoice after submission without AEAT detecting the tampering.

The mechanism is a SHA-256 hash chain: every RegistroAlta (invoice registration record) you send to AEAT must include the SHA-256 fingerprint of the previous record you sent for the same issuer. If you alter record N, the hash stored in record N+1 no longer matches, and AEAT's verification fails immediately.

The chaining block: EncadenandoRegistro

Each RegistroAlta sent to AEAT contains an EncadenandoRegistro block that references the immediately previous record:

{
  "EncadenandoRegistro": {
    "NumSerieFacturaAnterior":           "FACT-2026-099",
    "FechaExpedicionFacturaAnterior":    "09-05-2026",
    "HuellaFacturaAnterior":             "3A9F2B1..."
  }
}

The three fields work together: NumSerieFacturaAnterior and FechaExpedicionFacturaAnterior identify which previous record is being referenced, and HuellaFacturaAnterior is the SHA-256 fingerprint of that record's canonical string. AEAT stores both the record content and its fingerprint, so it can re-compute the hash at verification time and detect any mismatch.

Computing the SHA-256 fingerprint

The fingerprint (huella) of a record is the SHA-256 hash, in uppercase hexadecimal, of a canonical string constructed from the record's key fields. The canonical string format is:

IDEmisorFactura=<NIF>&NumSerieFactura=<series>&FechaExpedicionFactura=<date>&TipoFactura=<type>&CuotaTotal=<vat>&ImporteTotal=<total>&Huella=<prev-hash>&FechaHoraHusoHorarioFirma=<timestamp>

Fields are joined with & (ampersand, no spaces), in exactly this order. Values must be trimmed (no leading or trailing whitespace). The string is encoded as UTF-8 before hashing.

Field Example Notes
IDEmisorFactura B12345678 NIF of the invoice issuer
NumSerieFactura FACT-2026-100 Invoice series + number
FechaExpedicionFactura 09-05-2026 Format: DD-MM-YYYY
TipoFactura F1 F1 standard, F2 simplified, R1–R5 rectificativas
CuotaTotal 21.00 Total VAT amount, 2 decimal places, period separator
ImporteTotal 121.00 Invoice total inc. VAT, 2 decimal places
Huella 3A9F2B1… SHA-256 fingerprint of the previous record
FechaHoraHusoHorarioFirma 2026-05-09T14:23:45+02:00 Signing timestamp, ISO 8601 with timezone offset

Step-by-step: computing the hash in Python

import hashlib

def compute_huella(
    id_emisor: str,
    num_serie: str,
    fecha_expedicion: str,    # "DD-MM-YYYY"
    tipo_factura: str,        # "F1", "F2", "R1"...
    cuota_total: str,         # "21.00"
    importe_total: str,       # "121.00"
    huella_anterior: str,     # SHA-256 of previous record (hex uppercase)
    fecha_hora_firma: str,    # "2026-05-09T14:23:45+02:00"
) -> str:
    canonical = (
        f"IDEmisorFactura={id_emisor}&"
        f"NumSerieFactura={num_serie}&"
        f"FechaExpedicionFactura={fecha_expedicion}&"
        f"TipoFactura={tipo_factura}&"
        f"CuotaTotal={cuota_total}&"
        f"ImporteTotal={importe_total}&"
        f"Huella={huella_anterior}&"
        f"FechaHoraHusoHorarioFirma={fecha_hora_firma}"
    )
    digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
    return digest.upper()

# Invoice #100 — previous record's fingerprint was "3A9F2B1..."
huella_100 = compute_huella(
    id_emisor        = "B12345678",
    num_serie        = "FACT-2026-100",
    fecha_expedicion = "09-05-2026",
    tipo_factura     = "F1",
    cuota_total      = "21.00",
    importe_total    = "121.00",
    huella_anterior  = "3A9F2B1...",  # hash of record #99
    fecha_hora_firma = "2026-05-09T14:23:45+02:00",
)
# huella_100 is what you put in record #101's HuellaFacturaAnterior

The first record in a chain

Every issuer starts with a first invoice. There is no previous record, so what goes into HuellaFacturaAnterior?

For the very first RegistroAlta of an issuer, the chaining block is omitted entirely: you don't send EncadenandoRegistro at all. AEAT recognises the absence of this block as the chain initializer and stores the first record's own fingerprint as the starting point of the chain.

Common mistakes

1. Wrong decimal separator

CuotaTotal and ImporteTotal use a period (.) as the decimal separator, never a comma. Spain uses commas colloquially, but the canonical string follows the XML spec. 21,00 will produce a wrong hash.

2. Trailing zeros dropped

Always use exactly two decimal places: 21.00, not 21 or 21.0. The hash is computed over the literal string, so "21.00" and "21" produce different results.

3. Timezone offset in FechaHoraHusoHorarioFirma

The signing timestamp must include the local timezone offset, not UTC. In mainland Spain that's +01:00 in winter and +02:00 in summer (CEST). Using Z (UTC) when the clock says CET/CEST will produce a hash mismatch at verification time if AEAT re-derives the timestamp from the XML signature.

4. Re-hashing from stored invoice data

Once you've submitted record N and received AEAT's acknowledgement, store the computed Huella directly. Don't reconstruct it later from your database invoice fields; if any field was stored with a different format (extra spaces, different precision), re-computation will produce a different hash and your chain will be broken for record N+1.

What AEAT validates at verification time

When AEAT verifies your records (on demand or during an audit), they reconstruct the canonical string for each record from the stored field values, re-compute the SHA-256 hash, and check it against the HuellaFacturaAnterior value in the next record in sequence. If any record in the chain has been modified, even by a single character, every subsequent hash in the chain becomes invalid, and the sequence of failures pinpoints exactly where the chain was broken.

This is what makes VeriFactu more tamper-evident than a simple audit log: the chain is cryptographically self-verifying. There is no separate "audit hash" to maintain; the chain is the audit.

Cancellations and corrections

Cancellation records (RegistroAnulacion) and rectificative invoices (R1–R5) are also part of the chain. A cancellation record references the original invoice it cancels, but it still participates in the hash chain as a regular record. The next submission (whether another invoice or another cancellation) must include the fingerprint of the cancellation record.