Plugin Security Model
Understand what plugins can and cannot do, how config validation works, and how to handle secrets safely.
Plugins load as JavaScript modules in the open-wa Node.js process and run with that process's operating-system permissions. The SDK does not create a filesystem, network, or process sandbox, so review a plugin and its dependencies before loading it.
API restrictions
Cannot Emit Events
The events object supplied to init() exposes on(), once(), and off(); it has no emit() method. This limits access through that object, but it does not prevent plugin code from importing Node.js modules or using other host-process capabilities.
// This is NOT possible:
events.emit('custom.event', data); // No emit method existsInternal and sensitive events
The gateway checks the event metadata registered by the host. Events marked internal or sensitive are ignored and logged when a plugin subscribes; the policy is metadata-based, not a blanket block on particular name prefixes. Unknown event names are passed to the underlying emitter, so the gateway should not be treated as a sandbox or as an authorization boundary for arbitrary code.
No direct browser access through the SDK
PluginInput includes a client proxy, not a browser or CDP handle. The client can call WhatsApp methods exposed by window.WAPI; ask(method, args) forwards the requested method name to that runtime, so it is not a method allowlist. The SDK client interface does not expose methods to:
- Execute CDP commands
- Manipulate the browser DOM directly
- Access browser cookies or storage directly
- Inject scripts into the WhatsApp Web page
What the SDK does not isolate
Node's dynamic import() runs both a plugin's top-level code and init() in the host process. The event gateway and client proxy describe SDK interfaces; they do not limit a plugin's Node.js imports or OS permissions.
Review the plugin source, transitive dependencies, and requested permissions before installation. If code must be treated as untrusted, run it in a separately managed service with an operating-system or container boundary and communicate through an external API. Configure that isolation in your deployment environment; open-wa does not provide a plugin sandbox.
What Plugins Can Do
Subscribe to Events Allowed by the Gateway
events.on('message.received', (payload) => { ... });
events.once('client.ready', () => { ... });Call WhatsApp Methods Through the Client Proxy
The convenience methods and ask() call methods provided by the connected WhatsApp runtime:
await client.sendText('123@c.us', 'Hello');
await client.getHostNumber();
await client.ask('getAllChats');Mount HTTP routes
init: async () => ({
routes: () => {
const app = new Hono();
app.get('/health', (c) => c.json({ ok: true }));
return app;
},
})The host mounts the returned Hono app at /plugins/<name>/.
Declare AI Tools
init: async () => ({
tool: {
myTool: {
description: 'Does something useful',
args: { input: z.string() },
execute: async (args, ctx) => { ... },
},
},
})This declares a tool in the plugin hooks. The current MCP endpoint does not expose plugin-declared tools; see AI Tools for the host integration requirement.
Add dashboard pages
init: async () => ({
pages: [{ path: '/', title: 'Status', icon: '📊' }],
})The dashboard reads these declarations from the plugin manifest and renders its standard plugin status view. They do not grant a plugin a custom React component or a separate process.
Config Validation
How It Works
If you provide a configSchema in your plugin definition, the host validates the configuration before calling init().
export default createPlugin({
meta: { name: 'my-plugin' },
configSchema: z.object({
apiUrl: z.string().url(),
apiKey: z.string().min(1),
}),
init: async ({ config }) => {
// config is guaranteed to match the schema
},
});If Validation Fails
- The plugin will not register
- An error is logged with details about what failed
- During client creation, the registration error aborts plugin setup and the client start path; the current host does not skip the invalid plugin and continue with the remaining list
Handling Missing Config Gracefully
Use Zod defaults and optional fields:
const configSchema = z.object({
apiUrl: z.string().url().default('https://api.example.com'),
apiKey: z.string().optional(),
retries: z.number().int().positive().default(3),
});If apiKey is optional, your plugin must accept an undefined value:
init: async ({ config }) => {
if (!config.apiKey) {
logger.warn('No API key configured, some features will be disabled');
}
}Secret Management
Environment Variables
Reference environment variables in your wa.config.js:
// wa.config.js
export default {
pluginConfig: {
'my-plugin': {
apiKey: process.env.MY_PLUGIN_API_KEY,
},
},
};Read values from a .env file
If your app loads dotenv values, keep them in a .env file in the project root:
MY_PLUGIN_API_KEY=sk-abc123...The config file can read from process.env which picks up .env values if your setup loads them.
Are Config Values Logged?
Plugin config values are not automatically logged. But if your plugin logs the config object directly, secrets will appear in the logs. Always redact sensitive fields:
// BAD: logs the API key
logger.info('Config loaded', { config });
// GOOD: logs only non-sensitive fields
logger.info('Config loaded', { apiUrl: config.apiUrl, hasKey: !!config.apiKey });Validating API Keys Are Present
const configSchema = z.object({
apiKey: z.string().min(1, 'API key is required'),
});This will fail validation with a clear error message if the key is missing or empty.
Rotating Keys Without Restart
Currently, config is validated once at plugin initialization. To replace keys:
- Update the environment variable or config file
- Restart the session
- The plugin will reload with the new key
Error Handling Patterns
API Failures (Rate Limits, Timeouts, 401)
'message.received': async ({ message }) => {
try {
const result = await fetchExternalAPI(message);
} catch (error) {
if (error.status === 429) {
logger.warn('Rate limited, will retry', { retryAfter: error.retryAfter });
// Queue for retry
} else if (error.status === 401) {
logger.error('Invalid API key');
// Notify admin or disable feature
} else {
logger.error('API call failed', { error: error.message });
// Continue processing other messages
}
}
}Network Errors
try {
const response = await fetch(url, { signal: AbortSignal.timeout(10000) });
} catch (error) {
if (error.name === 'TimeoutError') {
logger.warn('Request timed out');
} else if (error.code === 'ECONNREFUSED') {
logger.error('Connection refused');
}
}Malformed Messages
Always validate message data before processing:
'message.received': async ({ message }) => {
const msg = message as { body?: string; from?: string };
if (!msg.body || !msg.from) {
logger.debug('Skipping malformed message');
return;
}
// Process the message
}WhatsApp Connection Drops
Use lifecycle hooks to detect connection state:
'core.stopping': async ({ reason }) => {
logger.info('Session stopping', { reason });
// Flush queues, save state
},
'client.ready': async ({ sessionId }) => {
logger.info('Reconnected', { sessionId });
// Resume processing
},Throw an error or continue
logger.error()and continue: For recoverable errors (network timeouts, rate limits), the plugin keeps processing other messages.- Throw from an event handler: The host catches the rejection and logs a
plugin_hook_error; it does not provide an automatic retry or unregister operation. - Throw from
init(): Registration fails and the client start path aborts, so use this for unrecoverable initialization errors. dispose(): Use this hook to release resources during host shutdown. The current API has no plugin-level unregister operation.- Queue for retry: For transient failures (network errors, rate limits), implement retry with exponential backoff.
Related
- Plugin getting started: Build your first plugin
- PluginInput breakdown: What your plugin receives
- External API patterns: Calling external services from plugins
Was this helpful?
Your answer includes the page path and docs version.
