documenso-security-basics
Implement security best practices for Documenso document signing integrations. Use when securing API keys, configuring webhooks securely, or implementing document security measures. Trigger with phrases like "documenso security", "secure documenso", "documenso API key security", "documenso webhook security". allowed-tools: Read, Write, Edit version: 1.0.0 license: MIT author: Jeremy Longshore <jeremy@intentsolutions.io>
Allowed Tools
No tools specified
Provided by Plugin
documenso-pack
Claude Code skill pack for Documenso (24 skills)
Installation
This skill is included in the documenso-pack plugin:
/plugin install documenso-pack@claude-code-plugins-plus
Click to copy
Instructions
# Documenso Security Basics
## Overview
Essential security practices for Documenso integrations including API key management, webhook security, and document protection.
## Prerequisites
- Documenso account with API access
- Understanding of environment variables
- Basic security concepts
## Instructions
### Step 1: Secure API Key Management
```typescript
// NEVER do this:
const client = new Documenso({
apiKey: "dcs_abc123...", // Hardcoded - BAD!
});
// ALWAYS do this:
const client = new Documenso({
apiKey: process.env.DOCUMENSO_API_KEY ?? "",
});
// Validate API key is present at startup
function validateEnvironment(): void {
const required = ["DOCUMENSO_API_KEY"];
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
throw new Error(
`Missing required environment variables: ${missing.join(", ")}`
);
}
// Validate API key format
const apiKey = process.env.DOCUMENSO_API_KEY!;
if (!apiKey.startsWith("dcs_")) {
console.warn("Warning: API key format unexpected (should start with dcs_)");
}
}
```
### Step 2: API Key Rotation
```typescript
// Support multiple API keys for rotation
interface KeyRotationConfig {
primaryKey: string;
secondaryKey?: string; // Fallback during rotation
}
async function getClientWithFallback(
config: KeyRotationConfig
): Promise {
// Try primary key first
try {
const client = new Documenso({ apiKey: config.primaryKey });
await client.documents.findV0({ perPage: 1 });
return client;
} catch (error: any) {
if (error.statusCode === 401 && config.secondaryKey) {
console.warn("Primary key failed, trying secondary...");
return new Documenso({ apiKey: config.secondaryKey });
}
throw error;
}
}
// Rotation procedure:
// 1. Generate new key in Documenso dashboard
// 2. Set as DOCUMENSO_API_KEY_SECONDARY
// 3. Test secondary key works
// 4. Swap: SECONDARY -> PRIMARY
// 5. Revoke old primary key
// 6. Remove secondary env var
```
### Step 3: Webhook Security
```typescript
import express from "express";
const app = express();
// Parse raw body for signature verification
app.use("/webhooks/documenso", express.raw({ type: "application/json" }));
app.post("/webhooks/documenso", (req, res) => {
// Verify webhook secret
const receivedSecret = req.headers["x-documenso-secret"];
const expectedSecret = process.env.DOCUMENSO_WEBHOOK_SECRET;
if (!expectedSecret) {
console.error("DOCUMENSO_WEBHOOK_SECRET not configured");
return res.status(500).json({ error: "Webhook not configured" });
}
// Timing-safe comparison to prevent timing attacks
if (!timingSafeEqual(receivedSecret as string, expectedSecret)) {
console.warn("Invalid webhook secret received");
return res.status(401).json({ error: "Invalid signature" });
}
// Parse and process
try {
const payload = JSON.parse(req.body.toString());
handleWebhookEvent(payload);
res.status(200).json({ received: true });
} catch (error) {
console.error("Webhook processing error:", error);
res.status(400).json({ error: "Invalid payload" });
}
});
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return require("crypto").timingSafeEqual(bufA, bufB);
}
```
### Step 4: Document Access Control
```typescript
// Track document ownership for access control
interface DocumentAccess {
documentId: string;
ownerId: string;
authorizedEmails: string[];
}
class DocumentAccessControl {
private accessMap = new Map();
async createDocument(
userId: string,
title: string,
authorizedEmails: string[]
): Promise {
const doc = await client.documents.createV0({ title });
const documentId = doc.documentId!;
this.accessMap.set(documentId, {
documentId,
ownerId: userId,
authorizedEmails,
});
return documentId;
}
canAccess(userId: string, documentId: string): boolean {
const access = this.accessMap.get(documentId);
if (!access) return false;
return access.ownerId === userId;
}
canSign(email: string, documentId: string): boolean {
const access = this.accessMap.get(documentId);
if (!access) return false;
return access.authorizedEmails.includes(email.toLowerCase());
}
}
```
### Step 5: Signing URL Security
```typescript
// Signing URLs should be treated as sensitive
// They grant access to sign documents
// DON'T expose signing URLs in logs
function logDocument(doc: any): void {
const safeDoc = { ...doc };
if (safeDoc.recipients) {
safeDoc.recipients = safeDoc.recipients.map((r: any) => ({
...r,
signingUrl: "[REDACTED]",
signingToken: "[REDACTED]",
}));
}
console.log(JSON.stringify(safeDoc, null, 2));
}
// DON'T store signing URLs in insecure locations
// They should be sent directly to recipients via secure channel
// DO set appropriate expiry on embedded signing sessions
async function getSecureSigningSession(
documentId: string,
recipientEmail: string
): Promise<{ signingUrl: string; expiresAt: Date }> {
const doc = await client.documents.getV0({ documentId });
const recipient = doc.recipients?.find((r) => r.email === recipientEmail);
if (!recipient?.signingUrl) {
throw new Error("Signing URL not available");
}
return {
signingUrl: recipient.signingUrl,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
};
}
```
### Step 6: Input Validation
```typescript
import { z } from "zod";
// Validate recipient input
const RecipientInputSchema = z.object({
email: z
.string()
.email("Invalid email format")
.transform((e) => e.toLowerCase().trim()),
name: z
.string()
.min(1, "Name is required")
.max(100, "Name too long")
.transform((n) => n.trim()),
role: z.enum(["SIGNER", "APPROVER", "VIEWER", "CC"]),
});
// Validate document title
const DocumentInputSchema = z.object({
title: z
.string()
.min(1, "Title is required")
.max(255, "Title too long")
.refine(
(t) => !/