all systems operationalall operational

apenaloza.cl · bitácora

SCL (UTC−3) · v2026.10

~/blog/sii-authentication-digital-certificate-nodejs.md

How to Authenticate with the SII Using a Digital Certificate in Node.js: Seed, Token, and mTLS

A practical guide to authenticating against the web services of Chile's tax authority (SII) with a digital certificate from Node.js: reading the .pfx, signing the seed with XMLDSig, getting the token, using mutual TLS, and fixing the most common errors.

author
Alejandro Peñaloza
Published
Reading
5 min
Cover: Authenticating to Chile's SII with a digital certificate, seed, token and mTLS from Node.js, with a certificate and key icon
In this article
  1. The digital certificate: what you actually have in your hands
  2. The two ways to authenticate
  3. Environments: certification and production
  4. Step 1: read the .pfx in Node.js
  5. Step 2: request the seed
  6. Step 3: sign the seed with XMLDSig
  7. Step 4: exchange the signed seed for a token
  8. Mutual TLS: when the service asks for the certificate on the connection
  9. Common errors and how to fix them
  10. Security best practices
  11. Conclusion

Integrating with Chile's tax authority (SII, Servicio de Impuestos Internos) is one of those jobs where the official documentation explains the what but rarely the how. Electronic invoicing, document lookups, purchase and sales registers: almost everything starts with the same step, proving to the SII who you are with a digital certificate.

In this post I walk through the full flow in Node.js, with code you can adapt and the errors that cost me the most time.

Note: SII services, URLs, and requirements change over time. Use this post as a guide and always check against the SII's current technical documentation before going to production.

The digital certificate: what you actually have in your hands

When you buy an electronic signature certificate from an accredited provider, you usually download it as a .pfx or .p12 file (PKCS#12 format) protected by a password. That file contains:

  • Your private key, which you sign with.
  • Your X.509 certificate, with your public key, your RUT (Chilean tax ID), and the issuer's details.
  • Sometimes, the provider's certificate chain.

Two requirements that people often forget:

  1. The certificate must be valid (not expired).
  2. The certificate owner must be authorized in the SII to act on behalf of the company (for example, as an administrator user or with the corresponding invoicing permissions). A valid certificate belonging to someone without permissions on that company RUT will authenticate, but everything after that fails.

The two ways to authenticate

Mechanism How it works Where it's used
Seed + token You request a seed, sign it with XMLDSig, and exchange it for a token that you send as a cookie Electronic tax document (DTE) web services and APIs that follow the same pattern
Mutual TLS (mTLS) Your HTTP client presents the certificate during the TLS handshake Services that require client certificate authentication

In both cases you need the same thing: the private key and the certificate extracted from the .pfx.

Environments: certification and production

The SII runs two separate environments, each with its own host:

Environment Host Use
Certification maullin.sii.cl Testing, the issuer certification process
Production palena.sii.cl Real operations

Make it configurable from day one. Mixing a certification token with a production service is a classic mistake.

const SII_HOST =
  process.env.SII_ENV === "production" ? "palena.sii.cl" : "maullin.sii.cl";

Step 1: read the .pfx in Node.js

I use node-forge because it lets you extract the key, the certificate, and the public key data that the SII requires inside the signature.

npm install node-forge xml-crypto fast-xml-parser
import fs from "node:fs";
import forge from "node-forge";

export function readCertificate(pfxPath: string, password: string) {
  const der = fs.readFileSync(pfxPath).toString("binary");
  const p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(der), password);

  const key = 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 (!key || !cert) throw new Error("The .pfx does not contain a key or certificate");

  const now = new Date();
  if (now < cert.validity.notBefore || now > cert.validity.notAfter) {
    throw new Error(`Certificate is not valid (expires ${cert.validity.notAfter.toISOString()})`);
  }

  const publicKey = cert.publicKey as forge.pki.rsa.PublicKey;
  const toBase64 = (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 {
    keyPem: forge.pki.privateKeyToPem(key),
    certPem: forge.pki.certificateToPem(cert),
    certBase64: forge.util.encode64(
      forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(),
    ),
    modulus: toBase64(publicKey.n),
    exponent: toBase64(publicKey.e),
  };
}

Step 2: request the seed

The seed is a single-use number issued by the CrSeed service. It's a classic SOAP web service: you send it an XML envelope and it replies with another envelope containing the seed XML escaped inside it.

import { XMLParser } from "fast-xml-parser";

const parser = new XMLParser({ ignoreAttributes: false });

const soapEnvelope = (method: string, body = "") => `<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
  <soapenv:Body><${method}>${body}</${method}></soapenv:Body>
</soapenv:Envelope>`;

async function callSoap(service: string, method: string, body = "") {
  const res = await fetch(`https://${SII_HOST}/DTEWS/${service}.jws`, {
    method: "POST",
    headers: { "Content-Type": "text/xml; charset=utf-8", SOAPAction: "" },
    body: soapEnvelope(method, body),
  });
  if (!res.ok) throw new Error(`${service}: HTTP ${res.status}`);

  const envelope = parser.parse(await res.text());
  const responseBody = envelope["soapenv:Envelope"]["soapenv:Body"];
  const result = Object.values(responseBody)[0] as Record<string, string>;
  const innerXml = Object.values(result).find((v) => typeof v === "string")!;
  return parser.parse(innerXml); // fast-xml-parser decodes the entities
}

export async function fetchSeed(): Promise<string> {
  const r = await callSoap("CrSeed", "getSeed");
  const resp = r["SII:RESPUESTA"];
  const status = String(resp["SII:RESP_HDR"].ESTADO);
  if (status !== "00") throw new Error(`Seed rejected, status ${status}`);
  return String(resp["SII:RESP_BODY"].SEMILLA);
}

The exact names of the response nodes may vary slightly. The first time, print the raw response with console.log and adjust how you access the nodes.

Step 3: sign the seed with XMLDSig

This is the step where almost everyone gets stuck. The SII expects an enveloped XML signature over this document:

<getToken><item><Semilla>012345678901</Semilla></item></getToken>

Requirements you must follow:

  • SHA-1 algorithms (rsa-sha1 for the signature and sha1 for the digest) for this service. Modern libraries default to SHA-256, so you have to configure it explicitly.
  • C14N canonicalization and the enveloped-signature transform.
  • A KeyInfo with KeyValue (RSA modulus and exponent) and X509Data with the certificate.
  • Do not reformat the XML after signing it: a single extra space or line break invalidates the signature.

With xml-crypto (the API changed between major versions; this is for version 6):

import { SignedXml } from "xml-crypto";

export function signSeed(seed: string, c: ReturnType<typeof readCertificate>) {
  const xml = `<getToken><item><Semilla>${seed}</Semilla></item></getToken>`;

  const signature = new SignedXml({
    privateKey: c.keyPem,
    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.modulus}</Modulus>` +
      `<Exponent>${c.exponent}</Exponent></RSAKeyValue></KeyValue>` +
      `<X509Data><X509Certificate>${c.certBase64}</X509Certificate></X509Data>`,
  });

  signature.addReference({
    xpath: "/*",
    uri: "",
    isEmptyUri: true,
    transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"],
    digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1",
  });

  signature.computeSignature(xml);
  return signature.getSignedXml();
}

Step 4: exchange the signed seed for a token

export async function fetchToken(c: ReturnType<typeof readCertificate>) {
  const seed = await fetchSeed();
  const signed = signSeed(seed, c);

  const r = await callSoap(
    "GetTokenFromSeed",
    "getToken",
    `<pszXml><![CDATA[${signed}]]></pszXml>`,
  );
  const resp = r["SII:RESPUESTA"];
  const status = String(resp["SII:RESP_HDR"].ESTADO);
  if (status !== "00") {
    throw new Error(`Token rejected, status ${status}: ${resp["SII:RESP_HDR"].GLOSA ?? ""}`);
  }
  return String(resp["SII:RESP_BODY"].TOKEN);
}

The token is sent as a cookie on subsequent requests:

await fetch(`https://${SII_HOST}/ruta/del/servicio`, {
  headers: { Cookie: `TOKEN=${token}` },
});

The token has a limited lifetime. Cache it (in memory or in Redis) and renew it when it expires or when the SII says it's no longer valid; requesting a new one for every call is slow and unnecessary.

sequenceDiagram
    participant App as Your service
    participant SII as SII
    App->>SII: getSeed (CrSeed)
    SII-->>App: SEMILLA
    App->>App: XMLDSig signature with private key
    App->>SII: getToken(signed seed)
    SII-->>App: TOKEN
    App->>SII: Requests with Cookie TOKEN=...

Mutual TLS: when the service asks for the certificate on the connection

Some services don't use a seed: they require the client to present the certificate during the TLS handshake. In Node.js you handle this with an https.Agent:

import https from "node:https";
import fs from "node:fs";

const agent = new https.Agent({
  pfx: fs.readFileSync(process.env.SII_PFX_PATH!),
  passphrase: process.env.SII_PFX_PASSWORD,
  keepAlive: true,
});

https.get("https://servicio.que.pide.certificado/", { agent }, (res) => {
  console.log(res.statusCode, res.headers["set-cookie"]);
});

If you use native fetch (built on undici), the equivalent is an undici Agent with connect: { pfx, passphrase } passed through the dispatcher option. Many of these flows return session cookies that you then have to send back, so a client with cookie handling is a good idea.

Common errors and how to fix them

"Unsupported PKCS12 PFX data" or "unsupported" when reading the .pfx

Since Node.js 17, which ships with OpenSSL 3, .pfx files encrypted with legacy algorithms (such as RC2-40, very common in certificates issued years ago) are rejected. The fix is to repackage it with modern algorithms:

openssl pkcs12 -in certificate.pfx -nodes -legacy -out temp.pem
openssl pkcs12 -export -in temp.pem -out certificate-modern.pfx
shred -u temp.pem   # the intermediate PEM holds the unencrypted key

The token is rejected even though the signature "looks fine"

  • Check that you used SHA-1 and not SHA-256.
  • Make sure you're not formatting the XML after signing (a prettyPrint breaks it).
  • Confirm that the KeyInfo includes the modulus, the exponent, and the certificate.
  • Validate the signature locally with xmlsec1 --verify before blaming the SII.

Everything works in certification and fails in production

It's almost always the host (maullin vs palena) or the certificate owner lacking permissions on the company in production.

Intermittent errors

The SII has maintenance windows and high-load periods (month-end closes, tax filing season). Implement retries with exponential backoff and log every response with its status code so you can diagnose issues.

Security best practices

  • Never commit the .pfx to the repository. Keep it in a secrets manager or, at the very least, outside the codebase with 600 permissions.
  • The certificate password belongs in an environment variable or a secrets manager, never in the code.
  • Centralize authentication in a single internal service that handles the certificate and hands out tokens; that way the certificate lives in exactly one place.
  • Track when the certificate expires and set up an alert weeks in advance. An expired certificate halts all invoicing.
  • Never log the signed seed or the full token.

Conclusion

Authenticating with the SII isn't hard, but it's unforgiving about details: the hash algorithm, the shape of the KeyInfo, one extra space, or a .pfx with legacy encryption is all it takes to lose an afternoon. With the seed, signature, and token flow neatly wrapped in a service, and the certificate treated as the critical secret it is, the rest of the integration becomes much more manageable.

share:linkedinxblueskywhatsappreddithacker newsfacebookemail

Comments

No comments yet. Want to leave the first one?

Leave a comment

Comments are reviewed before they go live. I don’t ask for your email or store your IP.

$ ls ~/blog | grep related

Keep reading