~/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
- Español
- Leer en español →

In this article
- The digital certificate: what you actually have in your hands
- The two ways to authenticate
- Environments: certification and production
- Step 1: read the .pfx in Node.js
- Step 2: request the seed
- Step 3: sign the seed with XMLDSig
- Step 4: exchange the signed seed for a token
- Mutual TLS: when the service asks for the certificate on the connection
- Common errors and how to fix them
- Security best practices
- 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:
- The certificate must be valid (not expired).
- 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-parserimport 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-sha1for the signature andsha1for 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
KeyInfowithKeyValue(RSA modulus and exponent) andX509Datawith 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 keyThe 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
prettyPrintbreaks it). - Confirm that the
KeyInfoincludes the modulus, the exponent, and the certificate. - Validate the signature locally with
xmlsec1 --verifybefore 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
600permissions. - 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
$ ls ~/blog | grep related
Keep reading

Your own Vercel on a VPS: deployments with Coolify and Dokploy
How to run your own deployment platform on a VPS with Coolify or Dokploy: git push to auto-deploy, Let's Encrypt SSL, databases and previews, plus an honest comparison with Vercel and its real limits.

War log: debugging CORS, refactoring my own code and integrating Webpay without losing the afternoon
Real problems and how I solved them: a CORS error that only failed in production, a memory leak on the server, my own year-old code refactored without shame, and a practical guide to integrating Webpay Plus, Mercado Pago and Flow.

Developer productivity: VS Code, advanced Git, agile project management and automation scripts
My daily workflow as a developer: the VS Code extensions and settings that actually help, Git beyond commit and push (rebase, cherry-pick and bisect), how to manage small projects without bureaucracy, and the scripts that automate the repetitive work.