OCPP WS IOocpp-ws-io

Security Profiles

Configuring OCPP Security Profiles (0-3).

ocpp-ws-io supports all OCPP security profiles out of the box.

Profile 0 — No Security (Development)

Use this for local development or trusted internal networks.

const client = new OCPPClient({
  endpoint: "ws://localhost:3000",
  identity: "CP001",
  protocols: ["ocpp1.6"],
  securityProfile: SecurityProfile.NONE,
});

Note: The server defaults to SecurityProfile.NONE if not specified.

Profile 1 — Basic Auth (Unsecured WS)

Uses HTTP Basic Authentication over an insecure WebSocket connection.

Client:

const client = new OCPPClient({
  endpoint: "ws://localhost:3000",
  identity: "CP001",
  protocols: ["ocpp1.6"],
  securityProfile: SecurityProfile.BASIC_AUTH,
  password: "my-secret-password", // Sent in Authorization header
});

The client sends password whenever it is set, with the identity as the username. A Buffer is sent as its bytes, so a binary key, such as 1.6's AuthorizationKey, arrives intact.

Server:

server.auth(async (ctx) => {
  const station = await db.findStation(ctx.handshake.identity);

  // OCPP-J §3.2: an identity the CSMS does not recognize SHOULD get 404
  if (!station) {
    return ctx.reject(404, "Unknown charging station");
  }

  // Buffer comparison to avoid timing attacks is recommended
  if (
    !ctx.handshake.password ||
    !ctx.handshake.password.equals(Buffer.from(station.password))
  ) {
    return ctx.reject(401, "Invalid credentials");
  }

  ctx.accept();
});

Under profiles 1 and 2 the server answers HTTP 401 with WWW-Authenticate: Basic on its own when a connection has no Basic Auth credentials, has an empty password, or uses a username other than its identity (OCPP 2.0.1 / 2.1 A00.FR.203/204 and A00.FR.302/303; the 1.6 security whitepaper says the same). Your auth callback is not called for those, and an AUTH_FAILED security event is emitted. Checking the password itself stays with your auth callback.

Set requireBasicAuth: false to leave that check to the auth callback as well; it then sees ctx.handshake.password as undefined. The option can be changed at runtime with server.reconfigure().

If a profile 1, 2 or 3 server accepts a connection with no auth callback registered for it, a warning is logged once: the password or client certificate is then not being checked.

Profile 2 — TLS + Basic Auth

Uses HTTP Basic Authentication over a secure WebSocket connection (wss://).

Client:

import fs from "fs";

const client = new OCPPClient({
  endpoint: "wss://csms.example.com",
  identity: "CP001",
  protocols: ["ocpp2.0.1"],
  securityProfile: SecurityProfile.TLS_BASIC_AUTH,
  password: "my-secret-password",
  tls: {
    // Standard Node.js TLSOptions
    ca: fs.readFileSync("./certs/ca.pem"),
    rejectUnauthorized: true,
  },
});

Server:

const server = new OCPPServer({
  protocols: ["ocpp2.0.1"],
  securityProfile: SecurityProfile.TLS_BASIC_AUTH,
  tls: {
    cert: fs.readFileSync("./certs/server.crt"),
    key: fs.readFileSync("./certs/server.key"),
  },
});

Certificates, TLS versions and ciphers

A CSMS has to support four cipher suites: two need an ECDSA certificate and two an RSA one, so "the CSMS will have to provide 2 different certificates" (OCPP 2.0.1 Part 2 §A). Pass both, in matching order:

const server = new OCPPServer({
  protocols: ["ocpp2.0.1"],
  securityProfile: SecurityProfile.TLS_BASIC_AUTH,
  tls: {
    cert: [fs.readFileSync("./certs/rsa.crt"), fs.readFileSync("./certs/ecdsa.crt")],
    key: [fs.readFileSync("./certs/rsa.key"), fs.readFileSync("./certs/ecdsa.key")],
    // minVersion: "TLSv1",  // only for legacy OCPP 1.6 charge points (security whitepaper)
    // ciphers: "...",       // OpenSSL cipher list, when you need to restrict it
  },
});

minVersion defaults to Node's "TLSv1.2", which OCPP 2.0.1 requires. The 1.6 security whitepaper lets charge points that cannot do TLS 1.2 use TLS 1.0/1.1; lower minVersion only for those. minVersion, maxVersion and ciphers work on OCPPClient too. server.updateTLS() keeps them, and the CA, when you replace only the certificate.

For anything else (SNI, OCSP stapling, a certificate per hostname), bring your own server: create it with any Node https options and pass it to server.listen(port, host, { server }), or attach server.handleUpgrade to its upgrade event.

Profile 3 — Mutual TLS (Client Certificates)

Uses Client Certificates for authentication. Basic Auth is skipped.

Client:

const client = new OCPPClient({
  endpoint: "wss://csms.example.com",
  identity: "CP001",
  protocols: ["ocpp2.0.1"],
  securityProfile: SecurityProfile.TLS_CLIENT_CERT,
  tls: {
    cert: fs.readFileSync("./certs/client.crt"),
    key: fs.readFileSync("./certs/client.key"),
    ca: fs.readFileSync("./certs/ca.pem"),
  },
});

Server:

const server = new OCPPServer({
  protocols: ["ocpp2.0.1"],
  securityProfile: SecurityProfile.TLS_CLIENT_CERT,
  tls: {
    cert: fs.readFileSync("./certs/server.crt"),
    key: fs.readFileSync("./certs/server.key"),
    ca: fs.readFileSync("./certs/ca.pem"), // profile 3 requests the client certificate itself
    rejectUnauthorized: true, // Reject clients without valid certs
  },
});

server.auth((ctx) => {
  const cert = ctx.handshake.clientCertificate;

  // Verify the certificate CN matches the identity (OCPP requirement)
  if (!cert || cert.subject?.CN !== ctx.handshake.identity) {
    return ctx.reject(401, "Certificate identity mismatch");
  }

  ctx.accept();
});

Charging Station Identity

Unknown identity. OCPP-J §3.2: "If the CSMS does not recognize the Charging Station identifier in the URL path, it SHOULD send an HTTP response with status 404". Only your application knows which chargers exist. Either reject with 404 in the auth callback, as in the Profile 1 example, or give the server a lookup that runs before middleware and auth:

const server = new OCPPServer({
  protocols: ["ocpp2.0.1"],
  isKnownIdentity: async (identity) => (await db.findStation(identity)) !== null,
});

false answers 404 and emits an AUTH_FAILED security event. A throw answers 500 and emits UPGRADE_ERROR, with the error logged on the server and kept out of the response. Under profile 1/2 a request without Basic Auth credentials gets 401 first, so it never reaches your database. Note that ctx.reject() without a code answers 401.

Identity format. OCPP 2.0.1 and 2.1 define the identity as an identifierString (a-z A-Z 0-9 * - _ = + | @ .) without :, because it is also the Basic Auth username, of at most 48 characters (OCPP-J §3.1.1; the : rule is "SHALL NOT" in 2.1 and in the 2.0.1 errata 2023-12). OCPP 1.6J sets no rule. With strictMode, the server applies these rules for the negotiated version. maxIdentityLength sets your own limit instead: it applies with or without strictMode and replaces the 48. A violation is answered with HTTP 400.

const server = new OCPPServer({
  protocols: ["ocpp2.0.1"],
  strictMode: true,        // identifierString, no ":", at most 48 characters
  maxIdentityLength: 32,   // optional: your own limit, enforced in any mode
});

Duplicate connection. OCPP does not say what to do when a charger connects while it is already connected. By default the new connection replaces the old one, which is usually a dead link the charger has not noticed yet after a network change (onEviction plugin hook). The old connection is closed with code 4000 and the reason "Evicted by new connection". A peer that does not answer the close frame is cut off after ws's closeTimeout (30 s; set it in wssOptions). Set duplicateConnection: "reject" to keep the old connection and refuse the new one with HTTP 409 instead. Both apply to connections on this server instance.

Payload Size Limit

By default, the server rejects any WebSocket frame larger than 64KB before it is JSON-parsed. This prevents a malicious client from sending a multi-gigabyte payload that would exhaust Node.js heap memory (OOM).

You can tune the limit with maxPayloadBytes:

const server = new OCPPServer({
  maxPayloadBytes: 131072, // 128 KB
});

64 KB is well above the maximum size of any valid OCPP message. Only raise this limit if you have a documented need for larger payloads.

TLS Certificate Hot-Reload

When running Node.js as a direct TLS edge server (Security Profile 2 or 3), certificates must be rotated before they expire — typically every 90 days with Let's Encrypt. Restarting the process drops all connected charging stations.

Use server.updateTLS() to swap certificates without disconnecting anyone:

import fs from "fs";

// Called from your cert-renewal hook (e.g. Certbot post-deploy script)
server.updateTLS({
  cert: fs.readFileSync("./certs/server.crt"),
  key: fs.readFileSync("./certs/server.key"),
});

Not needed if you terminate TLS at a reverse proxy (Nginx, AWS ALB, Traefik, etc.). In that case just reload the proxy — Node.js never sees the TLS layer.

Security Event Monitoring

The server emits a securityEvent for every security-relevant action. Hook into this for SIEM integration, alerting, or audit logging — no log parsing required.

server.on("securityEvent", (event) => {
  // event.type    — "AUTH_FAILED" | "UPGRADE_ERROR" | "CONNECTION_RATE_LIMIT" | "UPGRADE_ABORTED" | ...
  // event.identity — station ID (if known at the time)
  // event.ip       — remote IP address
  // event.timestamp — ISO 8601 string
  // event.details  — event-specific metadata
  console.log(event);
});

Event types

TypeWhen it fires
AUTH_FAILEDAuth callback rejected the connection (wrong password, bad cert), a profile 1/2 connection arrived without valid Basic Auth credentials, isKnownIdentity refused it (404), or duplicateConnection: "reject" refused it (409)
UPGRADE_ERRORSomething was thrown during the handshake instead of ctx.reject(): an exception in an auth callback, connection middleware or isKnownIdentity. The charger gets a bare 500; details.error and the server log hold the error
CONNECTION_RATE_LIMITA single IP exceeded the connection rate limit (connectionRateLimit)
UPGRADE_ABORTEDHandshake timed out or was aborted before auth completed

SIEM integration examples

Datadog:

server.on("securityEvent", (event) => {
  datadogMetrics.increment("ocpp.security_event", 1, [`type:${event.type}`]);
});

PagerDuty (alert on brute-force):

const failures = new Map<string, number>();

server.on("securityEvent", (event) => {
  if (event.type !== "AUTH_FAILED") return;
  const count = (failures.get(event.ip!) ?? 0) + 1;
  failures.set(event.ip!, count);
  if (count >= 10)
    pagerduty.createIncident({ title: `Brute-force from ${event.ip}` });
});

On this page