Security
Build plugins that protect your users' data.
SSP-specific rules
These are enforced by the platform or checked during review. Get them right first; the rest of this page is general good practice.
Credentials
pik_keys are per installation. A multi-tenant plugin must store them keyed byinstallation_id, encrypted at rest — never in a single shared environment variable.- Every secret is shown exactly once. API keys at provisioning or setup exchange; webhook secrets at provisioning, plugin creation, or rotation. Capture them straight into your secret store.
- Rotation has no overlap window. Regenerating a key or rotating a secret invalidates the old one immediately.
- Never expose credentials to the browser. Exchange setup tokens
server-side; the operator must never see a
pik_key. - Revoking a developer key also suspends any sandbox operator sessions it owns — that's your containment lever if one leaks.
Webhook endpoints
- Public HTTPS only. SSP refuses to deliver to private, loopback, link-local, or reserved addresses, and does not follow redirects.
- Verify
X-SSP-Signatureover the raw bytes, using a constant-time comparison. A body parser that consumes the stream first will break this. - Answer within 10 seconds. Verify, enqueue, return 2xx. Process asynchronously.
- Be idempotent. Three delivery attempts (60s / 300s / 900s backoff) mean duplicates are normal, not exceptional.
- Return 2xx for events you ignore. A non-2xx triggers retries that can never succeed.
Data handling
- Honour installation scope. A location-pinned installation must not be used to infer or store data beyond its location.
- Don't warehouse more than you need. Customer names, emails, phone numbers and delivery addresses are personal data belonging to the restaurant, not to you.
- Never log secrets or full payloads containing them. Log identifiers
(
installation_id,order_id), not bodies.
Idempotency keys are security-relevant
external_order_id and provider_transaction_id are what stop a retried
delivery-platform callback from creating a duplicate order or double-recording
a payment. Always send them.
Overview
Security is paramount when building integrations that handle sensitive data like payment information, customer details, and business metrics.
API Key Security
Generation
Generate cryptographically secure API keys:
// Node.js
const crypto = require('crypto');
const apiKey = crypto.randomBytes(32).toString('base64');
# Python
import secrets
api_key = secrets.token_urlsafe(32)
// PHP
$apiKey = bin2hex(random_bytes(32));
Storage
Never hardcode API keys:
❌ Bad:
const SSP_API_KEY = 'abc123xyz'; // DON'T DO THIS
✅ Good:
const SSP_API_KEY = process.env.SSP_API_KEY;
Environment Variables
Store all secrets in environment variables:
# .env (never commit to git!)
SSP_API_KEY=your-secure-key-here
PROVIDER_API_KEY=provider-key
WEBHOOK_SECRET=webhook-secret
DATABASE_URL=postgresql://...
Add to .gitignore:
.env
.env.local
.env.*.local
Rotation
Rotate API keys regularly:
// Support multiple keys during rotation
const validKeys = new Set([
process.env.SSP_API_KEY_CURRENT,
process.env.SSP_API_KEY_PREVIOUS // Keep for 24h
]);
function authenticate(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!validKeys.has(apiKey)) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
HTTPS/TLS
Enforce HTTPS
Always use HTTPS in production:
// Redirect HTTP to HTTPS
app.use((req, res, next) => {
if (req.header('x-forwarded-proto') !== 'https' &&
process.env.NODE_ENV === 'production') {
return res.redirect(`https://${req.header('host')}${req.url}`);
}
next();
});
Certificate Validation
Never disable certificate validation:
❌ Bad:
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // NEVER DO THIS
✅ Good:
const https = require('https');
const agent = new https.Agent({
rejectUnauthorized: true // Verify certificates
});
Input Validation
Validate All Inputs
const Joi = require('joi');
const chargeSchema = Joi.object({
amount: Joi.number().positive().max(1000000).required(),
currency: Joi.string().length(3).uppercase().required(),
customer: Joi.object({
email: Joi.string().email().required(),
phone: Joi.string().pattern(/^\+?[1-9]\d{1,14}$/)
}).required()
});
app.post('/charge', authenticate, async (req, res) => {
const { error, value } = chargeSchema.validate(req.body);
if (error) {
return res.status(400).json({
error: true,
message: 'Validation failed',
details: error.details
});
}
// Process validated data
await processCharge(value);
});
Sanitize Data
const validator = require('validator');
function sanitizeInput(input) {
if (typeof input === 'string') {
// Trim whitespace
input = input.trim();
// Escape HTML
input = validator.escape(input);
// Remove null bytes
input = input.replace(/\0/g, '');
}
return input;
}
Prevent SQL Injection
Use parameterized queries:
❌ Bad:
const query = `SELECT * FROM users WHERE email = '${email}'`; // SQL Injection!
✅ Good:
const query = 'SELECT * FROM users WHERE email = $1';
const result = await db.query(query, [email]);
Authentication
Timing-Safe Comparison
Prevent timing attacks:
const crypto = require('crypto');
function authenticate(req, res, next) {
const apiKey = req.headers['x-api-key'];
const expectedKey = process.env.SSP_API_KEY;
if (!apiKey || !expectedKey) {
return res.status(401).json({ error: 'Unauthorized' });
}
// Timing-safe comparison
const isValid = crypto.timingSafeEqual(
Buffer.from(apiKey),
Buffer.from(expectedKey)
);
if (!isValid) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
Rate Limiting
Prevent brute force attacks:
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: {
error: true,
message: 'Too many requests, please try again later'
},
standardHeaders: true,
legacyHeaders: false
});
app.use(limiter);
// Stricter limits for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Only 5 attempts
skipSuccessfulRequests: true
});
app.post('/auth/login', authLimiter, loginHandler);
Data Encryption
Encrypt Sensitive Data
const crypto = require('crypto');
class Encryption {
constructor(key) {
this.algorithm = 'aes-256-gcm';
this.key = Buffer.from(key, 'hex');
}
encrypt(text) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return {
iv: iv.toString('hex'),
encryptedData: encrypted,
authTag: authTag.toString('hex')
};
}
decrypt(encrypted) {
const decipher = crypto.createDecipheriv(
this.algorithm,
this.key,
Buffer.from(encrypted.iv, 'hex')
);
decipher.setAuthTag(Buffer.from(encrypted.authTag, 'hex'));
let decrypted = decipher.update(encrypted.encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
}
// Usage
const encryption = new Encryption(process.env.ENCRYPTION_KEY);
// Store encrypted
const credentials = { apiKey: 'secret', apiSecret: 'secret' };
const encrypted = encryption.encrypt(JSON.stringify(credentials));
await db.save(encrypted);
// Retrieve and decrypt
const stored = await db.find();
const decrypted = encryption.decrypt(stored);
const credentials = JSON.parse(decrypted);
Hash Passwords
Never store passwords in plain text:
const bcrypt = require('bcrypt');
// Hash password
const saltRounds = 12;
const hashedPassword = await bcrypt.hash(password, saltRounds);
// Verify password
const isValid = await bcrypt.compare(password, hashedPassword);
Webhook Security
Verify SSP signatures
SSP signs the raw request body with your webhook_secret and sends the hex
digest in X-SSP-Signature. There is no timestamp component — replay protection
comes from your own idempotency handling.
const crypto = require('crypto');
function verifySSP(rawBody, receivedHex, secret) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(receivedHex || '', 'utf8');
const b = Buffer.from(expected, 'utf8');
// timingSafeEqual throws on a length mismatch — check length first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw() is required: the HMAC covers the exact bytes SSP transmitted.
app.post('/webhooks/ssp', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifySSP(req.body, req.headers['x-ssp-signature'], process.env.SSP_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
res.json({ status: 'ok' });
});
Signing your own requests to SSP uses the same secret and the same scheme — see Authentication.
- Hashing
JSON.stringify(req.body)instead of the raw bytes — re-serializing changes key order and spacing. - Comparing with
==instead of a constant-time function.
Guard against replay
SSP retries each delivery up to three times, so the same event legitimately arrives more than once. Deduplicate on a natural key and persist the marker:
const key = `${event.event}:${event.installation_id}:${event.data.order_id}`;
if (!await db.processedEvents.insertIfAbsent({ key })) return; // already handled
Keep the marker in your database, not in memory — a restart must not replay.
Error Handling
Don't Leak Information
❌ Bad:
catch (error) {
res.status(500).json({
error: error.stack, // Leaks implementation details!
query: sqlQuery,
credentials: dbCredentials
});
}
✅ Good:
catch (error) {
// Log full error server-side
console.error('Error processing request:', {
error: error.message,
stack: error.stack,
requestId: req.id
});
// Return generic error to client
res.status(500).json({
error: true,
message: 'Internal server error',
request_id: req.id // For support lookup
});
}
Structured Logging
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// Don't log sensitive data
logger.info('Payment processed', {
transaction_id: 'txn_123',
amount: 100.00,
// DON'T: card_number, cvv, etc.
});
Dependency Security
Audit Dependencies
# Node.js
npm audit
npm audit fix
# Python
pip-audit
# PHP
composer audit
Keep Dependencies Updated
# Check for updates
npm outdated
# Update
npm update
# Major updates
npx npm-check-updates -u
npm install
Use Lock Files
Commit lock files to ensure consistent dependencies:
package-lock.json(Node.js)Pipfile.lock(Python)composer.lock(PHP)go.sum(Go)
CORS Configuration
Configure CORS properly:
const cors = require('cors');
// Don't allow all origins in production
❌ app.use(cors()); // Allows all origins!
// Whitelist specific origins
✅ app.use(cors({
origin: [
'https://api.ssppos.com'
],
methods: ['GET', 'POST'],
allowedHeaders: ['Content-Type', 'X-SSP-Signature', 'X-SSP-Event'],
credentials: true,
maxAge: 86400
}));
SQL Injection Prevention
Use ORMs or Parameterized Queries
// Sequelize ORM
const user = await User.findOne({
where: { email: email } // Safe
});
// Parameterized query
const result = await db.query(
'SELECT * FROM users WHERE email = $1',
[email] // Safe
);
// NEVER do this:
const query = `SELECT * FROM users WHERE email = '${email}'`; // DANGEROUS!
XSS Prevention
Sanitize Output
const validator = require('validator');
function sanitizeHTML(html) {
return validator.escape(html);
}
// In templates
<div>{{ sanitizeHTML(userInput) }}</div>
Content Security Policy
const helmet = require('helmet');
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https://api.ssppos.com']
}
}));
Secrets Management
Use Secret Management Services
// AWS Secrets Manager
const AWS = require('aws-sdk');
const secretsManager = new AWS.SecretsManager();
async function getSecret(secretName) {
const data = await secretsManager.getSecretValue({
SecretId: secretName
}).promise();
return JSON.parse(data.SecretString);
}
// Usage
const credentials = await getSecret('ssp/plugin/razorpay');
Environment-Specific Secrets
// config.js
module.exports = {
development: {
apiKey: process.env.DEV_API_KEY
},
production: {
apiKey: process.env.PROD_API_KEY
}
};
const config = require('./config')[process.env.NODE_ENV];
Security Headers
Use security headers:
const helmet = require('helmet');
app.use(helmet()); // Sets multiple security headers
// Or individually:
app.use(helmet.hidePoweredBy()); // Hide X-Powered-By
app.use(helmet.hsts()); // HTTP Strict Transport Security
app.use(helmet.noSniff()); // X-Content-Type-Options
app.use(helmet.xssFilter()); // X-XSS-Protection
app.use(helmet.frameguard()); // X-Frame-Options
Monitoring & Alerting
Security Monitoring
// Track failed auth attempts
let failedAttempts = {};
function trackFailedAuth(ip) {
failedAttempts[ip] = (failedAttempts[ip] || 0) + 1;
if (failedAttempts[ip] > 10) {
// Alert security team
alertSecurityTeam({
type: 'brute_force',
ip: ip,
attempts: failedAttempts[ip]
});
// Block IP
blockIP(ip);
}
}
Audit Logging
function auditLog(action, user, resource, result) {
logger.info('Audit', {
timestamp: new Date().toISOString(),
action: action,
user_id: user.id,
resource_type: resource.type,
resource_id: resource.id,
result: result, // 'success' or 'failure'
ip: user.ip
});
}
// Usage
auditLog('charge', user, { type: 'payment', id: 'txn_123' }, 'success');
Security Checklist
SSP-specific
-
pik_keys stored per installation, encrypted at rest -
webhook_endpointis a public HTTPS URL -
X-SSP-Signatureverified over the raw body, constant-time - Webhook handler returns 2xx within 10 seconds
- Webhook handling is idempotent and survives a restart
- Setup tokens exchanged server-side, never in the browser
- Idempotency keys sent on order creation and payment capture
- Secrets absent from logs and error reports
-
/healthis cheap and has no external dependencies - Installation location scope respected
General
- All secrets in environment variables
- HTTPS enforced
- API key authentication implemented
- Timing-safe comparison used
- Rate limiting configured
- Input validation on all endpoints
- SQL injection prevention (parameterized queries)
- XSS prevention (output sanitization)
- CORS configured properly
- Security headers set (helmet)
- Webhook signature verification
- Error messages don't leak info
- Dependencies audited
- Logging configured (no sensitive data)
- Monitoring and alerting set up
- Encryption for sensitive data at rest
- Password hashing (if applicable)
- Certificate validation enabled
Common Vulnerabilities
1. Mass Assignment
❌ Bad:
app.post('/users', async (req, res) => {
const user = await User.create(req.body); // User can set isAdmin!
});
✅ Good:
app.post('/users', async (req, res) => {
const { name, email } = req.body; // Only allow specific fields
const user = await User.create({ name, email });
});
2. Insecure Direct Object References
❌ Bad:
app.get('/transactions/:id', async (req, res) => {
const txn = await Transaction.findById(req.params.id); // Any ID!
res.json(txn);
});
✅ Good:
app.get('/transactions/:id', authenticate, async (req, res) => {
const txn = await Transaction.findOne({
id: req.params.id,
organization_id: req.user.organization_id // Check ownership
});
if (!txn) {
return res.status(404).json({ error: 'Not found' });
}
res.json(txn);
});
3. Server-Side Request Forgery (SSRF)
❌ Bad:
app.post('/fetch-url', async (req, res) => {
const data = await axios.get(req.body.url); // Can access internal services!
res.json(data);
});
✅ Good:
const { URL } = require('url');
app.post('/fetch-url', async (req, res) => {
const url = new URL(req.body.url);
// Whitelist allowed hosts
const allowedHosts = ['api.razorpay.com', 'api.stripe.com'];
if (!allowedHosts.includes(url.hostname)) {
return res.status(400).json({ error: 'Invalid URL' });
}
const data = await axios.get(url.toString());
res.json(data);
});
Resources
- OWASP Top 10
- Node.js Security Best Practices
- npm audit
- Snyk - Vulnerability scanning