~/blog/autenticacion-sii-certificado-digital-nodejs.md
Cómo autenticarse en el SII con certificado digital desde Node.js: semilla, token y mTLS
Guía práctica para autenticarse en los servicios del SII de Chile con un certificado digital desde Node.js: cómo leer el .pfx, firmar la semilla con XMLDSig, obtener el token, usar TLS mutuo y resolver los errores más comunes.
- autor
- Alejandro Peñaloza
- Publicado
- Lectura
- 5 min
- English
- Read in English →

En este artículo
- El certificado digital: lo que realmente tienes en las manos
- Las dos formas de autenticarse
- Ambientes: certificación y producción
- Paso 1: leer el .pfx en Node.js
- Paso 2: pedir la semilla
- Paso 3: firmar la semilla con XMLDSig
- Paso 4: cambiar la semilla firmada por un token
- TLS mutuo: cuando el servicio pide el certificado en la conexión
- Errores comunes y cómo resolverlos
- Buenas prácticas de seguridad
- Conclusión
Integrarse con el SII (Servicio de Impuestos Internos de Chile) es uno de esos trabajos donde la documentación oficial explica el qué pero rara vez el cómo. Facturación electrónica, consulta de documentos, registros de compras y ventas: casi todo parte por el mismo paso, demostrarle al SII quién eres usando un certificado digital.
En este post explico el flujo completo desde Node.js, con código listo para adaptar y los errores que más tiempo me hicieron perder.
Nota: los servicios, URLs y requisitos del SII cambian con el tiempo. Usa este post como guía y valida siempre contra la documentación técnica vigente del SII antes de pasar a producción.
El certificado digital: lo que realmente tienes en las manos
Cuando compras un certificado de firma electrónica a un proveedor acreditado, normalmente lo descargas como un archivo .pfx o .p12 (formato PKCS#12) protegido con una contraseña. Ese archivo contiene:
- Tu llave privada, con la que firmas.
- Tu certificado X.509, con tu llave pública, tu RUT y los datos del emisor.
- A veces, la cadena de certificados del proveedor.
Dos requisitos que se olvidan seguido:
- El certificado debe estar vigente.
- La persona dueña del certificado debe estar autorizada en el SII para actuar por la empresa (por ejemplo, como usuario administrador o con los permisos de facturación correspondientes). Un certificado válido de una persona sin permisos sobre ese RUT empresa se autentica, pero luego todo falla.
Las dos formas de autenticarse
| Mecanismo | Cómo funciona | Dónde se usa |
|---|---|---|
| Semilla + token | Pides una semilla, la firmas con XMLDSig y la cambias por un token que envías como cookie | Web services de documentos tributarios electrónicos (DTE) y APIs que siguen el mismo patrón |
| TLS mutuo (mTLS) | Tu cliente HTTP presenta el certificado durante el handshake TLS | Servicios que piden autenticación con certificado de cliente |
En ambos casos necesitas lo mismo: la llave privada y el certificado extraídos del .pfx.
Ambientes: certificación y producción
El SII separa dos ambientes, cada uno con su propio host:
| Ambiente | Host | Uso |
|---|---|---|
| Certificación | maullin.sii.cl |
Pruebas, proceso de certificación como emisor |
| Producción | palena.sii.cl |
Operación real |
Parametrízalo desde el inicio. Mezclar un token de certificación con un servicio de producción es un clásico.
const SII_HOST =
process.env.SII_AMBIENTE === "produccion" ? "palena.sii.cl" : "maullin.sii.cl";Paso 1: leer el .pfx en Node.js
Uso node-forge porque permite extraer la llave, el certificado y los datos de la llave pública que el SII pide dentro de la firma.
npm install node-forge xml-crypto fast-xml-parserimport fs from "node:fs";
import forge from "node-forge";
export function leerCertificado(rutaPfx: string, clave: string) {
const der = fs.readFileSync(rutaPfx).toString("binary");
const p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(der), clave);
const llave = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[
forge.pki.oids.pkcs8ShroudedKeyBag
]?.[0]?.key;
const cert = p12.getBags({ bagType: forge.pki.oids.certBag })[
forge.pki.oids.certBag
]?.[0]?.cert;
if (!llave || !cert) throw new Error("El .pfx no contiene llave o certificado");
const ahora = new Date();
if (ahora < cert.validity.notBefore || ahora > cert.validity.notAfter) {
throw new Error(`Certificado fuera de vigencia (vence ${cert.validity.notAfter.toISOString()})`);
}
const publica = cert.publicKey as forge.pki.rsa.PublicKey;
const aBase64 = (n: forge.jsbn.BigInteger) => {
let hex = n.toString(16);
if (hex.length % 2) hex = "0" + hex;
return forge.util.encode64(forge.util.hexToBytes(hex));
};
return {
llavePem: forge.pki.privateKeyToPem(llave),
certPem: forge.pki.certificateToPem(cert),
certBase64: forge.util.encode64(
forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(),
),
modulo: aBase64(publica.n),
exponente: aBase64(publica.e),
};
}Paso 2: pedir la semilla
La semilla es un número de un solo uso que entrega el servicio CrSeed. Es un web service SOAP clásico: le envías un sobre XML y responde otro sobre con el XML de la semilla escapado dentro.
import { XMLParser } from "fast-xml-parser";
const parser = new XMLParser({ ignoreAttributes: false });
const sobreSoap = (metodo: string, cuerpo = "") => `<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body><${metodo}>${cuerpo}</${metodo}></soapenv:Body>
</soapenv:Envelope>`;
async function llamarSoap(servicio: string, metodo: string, cuerpo = "") {
const res = await fetch(`https://${SII_HOST}/DTEWS/${servicio}.jws`, {
method: "POST",
headers: { "Content-Type": "text/xml; charset=utf-8", SOAPAction: "" },
body: sobreSoap(metodo, cuerpo),
});
if (!res.ok) throw new Error(`${servicio}: HTTP ${res.status}`);
const sobre = parser.parse(await res.text());
const cuerpoResp = sobre["soapenv:Envelope"]["soapenv:Body"];
const retorno = Object.values(cuerpoResp)[0] as Record<string, string>;
const xmlInterno = Object.values(retorno).find((v) => typeof v === "string")!;
return parser.parse(xmlInterno); // fast-xml-parser decodifica las entidades
}
export async function obtenerSemilla(): Promise<string> {
const r = await llamarSoap("CrSeed", "getSeed");
const resp = r["SII:RESPUESTA"];
const estado = String(resp["SII:RESP_HDR"].ESTADO);
if (estado !== "00") throw new Error(`Semilla rechazada, estado ${estado}`);
return String(resp["SII:RESP_BODY"].SEMILLA);
}Los nombres exactos de los nodos de respuesta pueden variar levemente. La primera vez, imprime la respuesta cruda con console.log y ajusta el acceso a los nodos.
Paso 3: firmar la semilla con XMLDSig
Este es el paso donde falla casi todo el mundo. El SII espera una firma XML envolvente (enveloped) sobre este documento:
<getToken><item><Semilla>012345678901</Semilla></item></getToken>Requisitos que debes respetar:
- Algoritmos SHA-1 (
rsa-sha1para la firma ysha1para el digest) en este servicio. Las librerías modernas usan SHA-256 por defecto, así que hay que configurarlo explícitamente. - Canonicalización C14N y la transformación enveloped-signature.
- Un
KeyInfoconKeyValue(módulo y exponente RSA) yX509Datacon el certificado. - No reformatear el XML después de firmarlo: un espacio o un salto de línea extra invalida la firma.
Con xml-crypto (la API cambió entre versiones mayores; esto corresponde a la versión 6):
import { SignedXml } from "xml-crypto";
export function firmarSemilla(semilla: string, c: ReturnType<typeof leerCertificado>) {
const xml = `<getToken><item><Semilla>${semilla}</Semilla></item></getToken>`;
const firma = new SignedXml({
privateKey: c.llavePem,
publicCert: c.certPem,
signatureAlgorithm: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
canonicalizationAlgorithm: "http://www.w3.org/TR/2001/REC-xml-c14n-20010315",
getKeyInfoContent: () =>
`<KeyValue><RSAKeyValue><Modulus>${c.modulo}</Modulus>` +
`<Exponent>${c.exponente}</Exponent></RSAKeyValue></KeyValue>` +
`<X509Data><X509Certificate>${c.certBase64}</X509Certificate></X509Data>`,
});
firma.addReference({
xpath: "/*",
uri: "",
isEmptyUri: true,
transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"],
digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1",
});
firma.computeSignature(xml);
return firma.getSignedXml();
}Paso 4: cambiar la semilla firmada por un token
export async function obtenerToken(c: ReturnType<typeof leerCertificado>) {
const semilla = await obtenerSemilla();
const firmado = firmarSemilla(semilla, c);
const r = await llamarSoap(
"GetTokenFromSeed",
"getToken",
`<pszXml><![CDATA[${firmado}]]></pszXml>`,
);
const resp = r["SII:RESPUESTA"];
const estado = String(resp["SII:RESP_HDR"].ESTADO);
if (estado !== "00") {
throw new Error(`Token rechazado, estado ${estado}: ${resp["SII:RESP_HDR"].GLOSA ?? ""}`);
}
return String(resp["SII:RESP_BODY"].TOKEN);
}El token se envía en las siguientes peticiones como cookie:
await fetch(`https://${SII_HOST}/ruta/del/servicio`, {
headers: { Cookie: `TOKEN=${token}` },
});El token tiene una vigencia limitada. Cachéalo (en memoria o en Redis) y renuévalo cuando expire o cuando el SII responda que no es válido; pedir uno nuevo por cada petición es lento e innecesario.
sequenceDiagram
participant App as Tu servicio
participant SII as SII
App->>SII: getSeed (CrSeed)
SII-->>App: SEMILLA
App->>App: Firma XMLDSig con llave privada
App->>SII: getToken(semilla firmada)
SII-->>App: TOKEN
App->>SII: Peticiones con Cookie TOKEN=...
TLS mutuo: cuando el servicio pide el certificado en la conexión
Algunos servicios no usan semilla: piden que el cliente presente el certificado durante el handshake TLS. En Node.js se resuelve con un https.Agent:
import https from "node:https";
import fs from "node:fs";
const agente = new https.Agent({
pfx: fs.readFileSync(process.env.SII_PFX_PATH!),
passphrase: process.env.SII_PFX_CLAVE,
keepAlive: true,
});
https.get("https://servicio.que.pide.certificado/", { agent: agente }, (res) => {
console.log(res.statusCode, res.headers["set-cookie"]);
});Si usas fetch nativo (basado en undici), el equivalente es un Agent de undici con connect: { pfx, passphrase } pasado en la opción dispatcher. Muchos de estos flujos devuelven cookies de sesión que luego debes reenviar, así que conviene un cliente con manejo de cookies.
Errores comunes y cómo resolverlos
"Unsupported PKCS12 PFX data" o "unsupported" al leer el .pfx
Desde Node.js 17, que usa OpenSSL 3, los .pfx cifrados con algoritmos antiguos (como RC2-40, muy común en certificados emitidos hace años) se rechazan. La solución es reempaquetarlo con algoritmos modernos:
openssl pkcs12 -in certificado.pfx -nodes -legacy -out temporal.pem
openssl pkcs12 -export -in temporal.pem -out certificado-moderno.pfx
shred -u temporal.pem # el PEM intermedio tiene la llave sin cifrarEl token se rechaza aunque la firma "se ve bien"
- Revisa que usaste SHA-1 y no SHA-256.
- Verifica que no estés formateando el XML después de firmar (un
prettyPrintlo rompe). - Confirma que el
KeyInfotrae el módulo, el exponente y el certificado. - Valida la firma localmente con
xmlsec1 --verifyantes de culpar al SII.
Todo funciona en certificación y falla en producción
Casi siempre es el host (maullin vs palena) o que la persona del certificado no tiene los permisos sobre la empresa en producción.
Errores intermitentes
El SII tiene ventanas de mantención y momentos de alta carga (cierres de mes, declaraciones). Implementa reintentos con espera exponencial y registra cada respuesta con su código de estado para poder diagnosticar.
Buenas prácticas de seguridad
- Nunca subas el .pfx al repositorio. Guárdalo en un gestor de secretos o, como mínimo, fuera del código con permisos
600. - La contraseña del certificado va en una variable de entorno o gestor de secretos, nunca en el código.
- Centraliza la autenticación en un solo servicio interno que maneje el certificado y entregue tokens; así el certificado vive en un solo lugar.
- Registra cuándo vence el certificado y crea una alerta con semanas de anticipación. Un certificado vencido detiene la facturación completa.
- No registres en los logs ni la semilla firmada ni el token completo.
Conclusión
Autenticarse en el SII no es difícil, pero es implacable con los detalles: el algoritmo de hash, la forma del KeyInfo, un espacio de más o un .pfx con cifrado antiguo bastan para perder una tarde. Con el flujo semilla, firma y token bien encapsulado en un servicio, y el certificado tratado como el secreto crítico que es, el resto de la integración se vuelve mucho más llevadero.
compartir:linkedinxblueskywhatsappreddithacker newsfacebookcorreo
Comentarios
Aún no hay comentarios. ¿Quieres dejar el primero?
Deja un comentario
$ ls ~/blog | grep relacionados
Sigue leyendo

Tu propio Vercel en un VPS: despliegues con Coolify y Dokploy
Cómo montar una plataforma de despliegue propia en un VPS con Coolify o Dokploy: push a Git y despliegue automático, SSL con Let's Encrypt, bases de datos y previews, más una comparación honesta con Vercel y sus límites reales.

Bitácora de guerra: depurar CORS, refactorizar código propio e integrar Webpay sin perder la tarde
Problemas reales y cómo los resolví: un error de CORS que solo fallaba en producción, una fuga de memoria en el servidor, código mío de hace un año refactorizado sin vergüenza y una guía práctica para integrar Webpay Plus, Mercado Pago y Flow.

Productividad para desarrolladores: VS Code, Git avanzado, gestión ágil y scripts de automatización
Mi flujo de trabajo diario como desarrollador: las extensiones y ajustes de VS Code que sí aportan, Git más allá de commit y push (rebase, cherry-pick y bisect), cómo gestionar proyectos pequeños sin burocracia y los scripts que automatizan lo repetitivo.