Skip to main content
A Volunteer partner integration holds the keys to a customer’s entire volunteer dataset. The customer’s Bearer token grants full read access to their org’s Users, Projects, Groups, Forms, and Form Completions — and write access to the limited set of endpoints. Lose the token, and a third party can read every volunteer record (and modify Users, Projects, and Groups). The security patterns on this page aren’t optional bureaucracy — they’re the foundation of customer trust. This page covers the security practices that matter: how to handle tokens, where to store them, how to minimize blast radius when something goes wrong, and the audit and operational patterns that surround them. If you haven’t yet, skim Authentication for the basic token model.

Principle 1: tokens are credentials, not configuration

A common mistake: treating VOMO tokens like ordinary configuration values — environment variables, config files, deployment manifests. They’re not. They’re secrets that should be handled with the same care as database passwords or API keys.

What this means in practice

Anti-patternWhy it’s wrongBetter
Token in environment variable on productionAnyone with deploy access sees themSecrets manager with audit logging
Token in config file checked into gitPermanent leak in source history.gitignore + secrets manager
Token in CI/CD logsLogs are often broadly accessibleMask in logs; never echo
Token sent in customer support emailEmail is not a secure channelSecure customer portal upload
Token in screenshots / docsPermanent visual leakRedact in any visual capture
Same token across multiple environments (dev/prod)A dev leak compromises productionSeparate tokens per environment

The “secrets in env vars” trap

It’s tempting to dismiss the env-var concern — “we control the production environment, only admins can see env vars.” But:
  • Container orchestrators often dump env vars in logs during debugging
  • Crash reports may include process memory
  • Sidecar containers and observability tools sometimes capture env state
  • A developer with prod access becomes a single point of compromise
For low-stakes integrations, env vars are acceptable risk. For partner integrations holding many customers’ tokens, secrets managers are essential.

Principle 2: defense in depth — multiple layers

No single layer of protection is enough. Layer them: Each layer is independent. If one fails (e.g., a misconfigured IAM rule), the others provide backstops (the secrets manager logs the unusual access; rotation limits how long the exposure lasts).

Layer by layer

LayerPurpose
Encryption at restTokens encrypted in the database; only decrypted in memory when needed
Secrets managerTokens stored in a purpose-built service (not your application DB)
Access controlOnly specific service accounts can read decrypted tokens
Audit loggingEvery access to a token is logged with who, when, why
RotationTokens rotated on a schedule; old tokens expire
Egress restrictionsNetwork policies prevent token from leaving the system in unexpected ways

Principle 3: per-customer credential isolation

For multi-tenant partner integrations, each customer’s token must be isolated from other customers’ tokens. A bug or compromise affecting one customer’s credentials shouldn’t cascade.

Storage pattern

JavaScript
class CredentialStore {
  constructor({ secretsManager, encryptionKeyId }) {
    this.secretsManager = secretsManager;
    this.encryptionKeyId = encryptionKeyId;
  }

  async storeToken(customerId, token) {
    // 1. Encrypt with per-customer-derived key
    const customerKey = await this._deriveCustomerKey(customerId);
    const encrypted = await encrypt(token, customerKey);

    // 2. Store in secrets manager
    await this.secretsManager.put(`customer/${customerId}/vomo_token`, encrypted);

    // 3. Audit log
    await audit.log({
      event: 'token_stored',
      customer_id: customerId,
      actor: getCurrentActor(),
      timestamp: new Date(),
    });
  }

  async getToken(customerId, reason) {
    // Every access requires a reason — for audit trail
    if (!reason) throw new Error('Token access requires a reason');

    const encrypted = await this.secretsManager.get(`customer/${customerId}/vomo_token`);
    const customerKey = await this._deriveCustomerKey(customerId);
    const token = await decrypt(encrypted, customerKey);

    await audit.log({
      event: 'token_accessed',
      customer_id: customerId,
      actor: getCurrentActor(),
      reason,
      timestamp: new Date(),
    });

    return token;
  }
}
The customer-derived key means even if the secrets manager’s master key is compromised, decrypting each customer’s token requires the per-customer derivation step.

Why “every access requires a reason”

Forcing a reason parameter is a code-organization tactic: it makes it visible in code review when token access is happening. A grep for getToken( shows every site that pulls a token; the reason string explains why. Common reasons:
  • 'polling_cycle'
  • 'manual_backfill'
  • 'reconciliation'
  • 'customer_dashboard'
  • 'health_check'
Anything else is suspicious and worth scrutinizing during review.

Principle 4: minimize token exposure in memory

Once decrypted, the token lives in memory. Minimize how long and where:

Pattern: short-lived decrypted handles

JavaScript
async function withVomoToken(customerId, reason, fn) {
  const token = await credentialStore.getToken(customerId, reason);
  try {
    return await fn(token);
  } finally {
    // Best-effort scrub — JavaScript doesn't give strong guarantees here
    // but the principle is to not keep tokens around longer than needed
    token.value = null;
  }
}

// Usage
await withVomoToken(customerId, 'polling_cycle', async (token) => {
  // Use the token here
  await pollUsers(customerId, token);
  // Token goes out of scope when the function returns
});
The decrypted token is scoped to the function that needs it. After the function returns, the reference is released; the runtime’s garbage collector eventually reclaims the memory.

Don’t put tokens in long-lived caches

A common anti-pattern: caching decrypted tokens for performance. This trades a tiny performance benefit for substantial security risk.
JavaScript
// ❌ Anti-pattern: caching decrypted tokens
const tokenCache = new Map();
async function getCachedToken(customerId) {
  if (tokenCache.has(customerId)) return tokenCache.get(customerId);
  const token = await credentialStore.getToken(customerId, 'polling');
  tokenCache.set(customerId, token); // Now in memory for the process lifetime
  return token;
}
The “cached” token is now exposed for the lifetime of the worker process — and visible in heap dumps, error reports, and debugging tools. Better: cache only metadata (token expiry, partial fingerprint for logging), not the token itself.

Principle 5: scope minimization

Customers issue tokens via the VOMO admin portal. The token grants access to the customer’s organization — there’s no fine-grained scoping by resource type in the API itself. But the partner integration can choose to use the token more or less broadly. Principle: use the token for the narrowest set of operations needed for the workflow.

Pattern: per-workflow token use

JavaScript
class UserSyncWorker {
  constructor({ customerId }) {
    this.customerId = customerId;
  }

  async runOneCycle() {
    // Read-only operations
    const usersToken = await credentialStore.getToken(this.customerId, 'user_sync_read');
    const users = await listUsers(usersToken);
    // ...
  }

  async pushUpdate(externalUpdate) {
    // Write operation
    const writeToken = await credentialStore.getToken(this.customerId, 'user_sync_write');
    await upsertUser(writeToken, externalUpdate);
    // ...
  }
}
Even though usersToken and writeToken are the same token (VOMO doesn’t issue separate read/write tokens), the audit log separates the two uses. If a compromise is detected, the log shows whether the integration was reading or writing at the moment.

Future-proofing for finer-grained scoping

If VOMO ever introduces fine-grained scopes, integrations that have been careful about declaring operation intent will be ready. Integrations that treat the token monolithically will need refactoring.
The path from customer → partner is where most token compromises happen:
Handoff methodRisk
Customer pastes token into a partner web formToken transits over TLS; partner stores it
Customer emails token to partner supportEmail is not encrypted; recipients vary
Customer texts/IMs tokenMessaging is often archived; clients are diverse
Customer types token into a screen-shared sessionVisible in screen recording
Customer commits token to a shared git repoCatastrophic — appears in history

Pattern: secure-form-based handoff

The right pattern: customer-facing form on the partner’s web UI, TLS-encrypted, that captures the token and immediately encrypts it for storage:
JavaScript
// Partner web UI form handler
app.post('/customer/onboarding/vomo-token', requireAuthenticatedCustomer, async (req, res) => {
  const customerId = req.session.customerId;
  const token = req.body.vomoToken;

  if (!token || typeof token !== 'string' || token.length < 20) {
    return res.status(400).json({ error: 'Invalid token format' });
  }

  // Test the token works
  const valid = await testVomoToken(token);
  if (!valid) {
    return res.status(400).json({ error: 'VOMO rejected this token' });
  }

  // Store securely
  await credentialStore.storeToken(customerId, token);

  // Don't echo the token back in the response
  res.json({ success: true });
});

Customer-side guidance to include in onboarding

Document for the customer:
  • The token grants full access to their VOMO data — treat it like a password
  • Don’t share via email, chat, or unencrypted channels
  • The form-based handoff is the only secure path
  • After submission, the partner stores it encrypted
  • Notify the partner immediately if the customer suspects the token was exposed
  • Use the customer’s VOMO admin portal to revoke/rotate the token
Build a one-page security FAQ for customers. It’s a foundation of customer trust.

Principle 7: rotation

Even with perfect storage, tokens should rotate periodically. Rotation:
  • Limits the exposure window if a token is leaked
  • Forces audit of which integrations are actually using which tokens
  • Aligns with security best practices for compliance audits

Rotation patterns

Customer-initiated rotation: The customer generates a new token in the VOMO admin portal; updates the partner integration; the old token is revoked. The integration uses the new token for verification (test query); only after verification does it commit to the new token; then the customer revokes the old. Scheduled rotation reminder: Partner sends customers an email every N months reminding them to rotate.
JavaScript
async function sendRotationReminders() {
  const customers = await db.getCustomersWithStaleTokens(180); // 6 months
  for (const customer of customers) {
    await emailService.send({
      to: customer.adminEmail,
      template: 'token_rotation_reminder',
      data: { customerName: customer.name, daysSinceRotation: customer.daysSinceRotation },
    });
  }
}
For most B2B integrations, 180 days (6 months) is a reasonable cadence — frequent enough to limit exposure, infrequent enough to avoid rotation fatigue.

Principle 8: audit everything

Comprehensive audit logging is a security primitive. Every meaningful event should be logged:
EventWhat to log
Token storagecustomer_id, actor, timestamp; not the token itself
Token accesscustomer_id, actor, reason, timestamp
Token rotationcustomer_id, actor, timestamp; old and new fingerprints (not full tokens)
Failed authentication (401 from VOMO)customer_id, request context, timestamp
Customer onboardingcustomer_id, who initiated, configuration set
Customer offboardingcustomer_id, what data was deleted/retained
Data access patterns (per-record)customer_id, resource type, ID, operation, trace ID

Audit log integrity

The audit log itself can be a target — an attacker may try to delete or modify entries to cover tracks. Mitigations:
  • Append-only design (no UPDATE or DELETE statements supported)
  • Separate access control — the application can write but only ops can read
  • Off-system replication — entries also sent to a separate log aggregation system
  • Tamper-evident chaining — each entry includes a hash of the previous (blockchain-style)
For most partner integrations, append-only + separate access control + log aggregation is sufficient.

Retention

How long to keep audit logs:
Audit typeRetention
Security events (auth failures, rotations)At least 1 year
Operational events (polling, processing)30-90 days
Per-record access logs30-90 days
Customer onboarding/offboardingPermanent (or per legal requirement)
Longer retention provides better forensics but costs more storage. Tier by importance.

Principle 9: secure handling of PII

Volunteer data is PII (personally identifiable information). Names, emails, phone numbers, addresses, birthdays — all of it is regulated under various privacy laws (GDPR in EU, CCPA in California, etc.).

Practices that matter

PracticeWhat it does
Encryption in transit (HTTPS only)Prevents network interception
Encryption at restProtects against database breaches
Minimum data retentionReduces blast radius
Per-customer data isolationPrevents cross-customer leaks
Customer-initiated data deletionComplies with right-to-be-forgotten requests
Access loggingEnables audit of who saw what
Secure deletionEnsures deleted means deleted (not just marked)
Subprocessor disclosureDocument what third-party services see customer data

The data minimization pattern

For partner integrations, don’t store PII you don’t need. If you only need to know “Bruce participated in 5 projects last month,” you don’t need to store his birthday, gender, address. Trimming the data you persist limits exposure:
JavaScript
// ❌ Anti-pattern: store everything
await db.upsertPerson({
  vomo_user_id: user.id,
  first_name: user.first_name,
  last_name: user.last_name,
  email: user.email,
  phone: user.phone,           // Do you need this?
  birthday: user.birthday,     // Or this?
  address: user.address,       // Or this?
  gender: user.gender,         // Or this?
  // ... all fields ...
});

// ✅ Better: store only what your workflows need
await db.upsertPerson({
  vomo_user_id: user.id,
  display_name: user.full_name,  // composite if you don't need first/last separately
  email: user.email,             // needed for join key
  // skip phone/birthday/address/gender if your workflows don't use them
});
For some integrations this is dramatic — moving from “mirror everything” to “store only what we use” cuts PII exposure to 20% of what it would have been.

Customer-initiated deletion

For GDPR Article 17 (right to erasure) compliance, support customer-initiated deletion of specific persons:
JavaScript
async function deletePerson(customerId, partnerPersonId, reason) {
  // 1. Anonymize the canonical person record (keep ID; clear PII)
  await db.query(`
    UPDATE persons SET
      first_name = NULL,
      last_name = NULL,
      email = CONCAT('deleted-', partner_person_id, '@anonymized.local'),
      anonymized_at = NOW(),
      anonymization_reason = $3
    WHERE customer_id = $1 AND partner_person_id = $2
  `, [customerId, partnerPersonId, reason]);

  // 2. Delete from related tables that hold PII
  await db.query(`DELETE FROM ... WHERE partner_person_id = ...`);

  // 3. Audit log
  await audit.log({
    event: 'person_anonymized',
    customer_id: customerId,
    partner_person_id: partnerPersonId,
    reason,
    actor: getCurrentActor(),
  });
}
Anonymization (rather than full deletion) preserves the structural integrity of historical reports while removing PII. Aggregate stats remain correct; the person can no longer be identified.

Principle 10: customer offboarding

When a customer cancels the integration, secure handling of their credentials and data is essential.

Offboarding checklist

JavaScript
async function offboardCustomer(customerId) {
  const trace = generateTraceId();

  // 1. Disable polling and reconciliation immediately
  await db.upsert('sync_configs', {
    customer_id: customerId,
    enabled: false,
    offboarded_at: new Date(),
  });
  await jobScheduler.removeAllForCustomer(customerId);

  // 2. Revoke the token's use within the integration
  await credentialStore.deleteToken(customerId);

  // 3. Audit the offboarding
  await audit.log({
    event: 'customer_offboarded',
    customer_id: customerId,
    actor: getCurrentActor(),
    trace_id: trace,
  });

  // 4. Schedule data retention per agreement
  const retentionDays = await getRetentionPolicy(customerId);
  await jobScheduler.scheduleOneTime({
    type: 'final_data_deletion',
    customerId,
    delayDays: retentionDays,
    trace_id: trace,
  });

  // 5. Notify customer
  await emailService.send({
    to: customer.adminEmail,
    template: 'offboarding_confirmation',
    data: { customerId, retentionDays },
  });
}

async function finalDataDeletion(customerId, traceId) {
  // After the retention period, permanently delete
  await db.query(`DELETE FROM persons WHERE customer_id = $1`, [customerId]);
  await db.query(`DELETE FROM participations WHERE customer_id = $1`, [customerId]);
  await db.query(`DELETE FROM projects WHERE customer_id = $1`, [customerId]);
  // ... all customer-scoped tables ...

  // Log the final deletion (audit logs themselves are retained)
  await audit.log({
    event: 'final_data_deletion_complete',
    customer_id: customerId,
    trace_id: traceId,
  });
}

Why a retention period

Customers sometimes cancel and then re-enable (changed their mind, business needs evolved). A retention period (30-90 days) lets them resume without full re-onboarding. But: communicate the retention period clearly during offboarding, including the final-deletion date. For privacy-sensitive customers, offer immediate deletion as an opt-in.

Principle 11: respond to compromise quickly

When you suspect a credential has been compromised — leaked in a log, exposed in a code review, accessed without authorization — the response should be immediate:

Incident response sequence

StepAction
DetectAlert fires, audit log review surfaces concern, customer reports issue
ContainImmediately disable the affected customer’s integration; revoke their token via VOMO admin if possible
InvestigateWhat was accessed? What was the scope? When did it start?
NotifyCustomer first (always), then internal stakeholders, then any regulatory bodies if required
RecoverCustomer issues new token; partner stores; integration resumes
DocumentWhat happened, what was the impact, what’s being done to prevent recurrence

Speed matters

The window between detection and containment is the highest-risk period. Build tooling that makes containment fast:
JavaScript
async function emergencyDisableCustomer(customerId, reason) {
  // Single command that:
  // 1. Disables polling
  // 2. Cancels in-flight jobs
  // 3. Marks token as compromised
  // 4. Alerts internal team
  // 5. Logs incident

  await db.upsert('sync_configs', {
    customer_id: customerId,
    enabled: false,
    emergency_disabled_at: new Date(),
    emergency_disabled_reason: reason,
  });

  await jobScheduler.cancelAllForCustomer(customerId);
  await credentialStore.markCompromised(customerId);

  await alertOps({
    severity: 'critical',
    customerId,
    type: 'emergency_disable',
    reason,
  });

  await audit.log({
    event: 'emergency_disable',
    customer_id: customerId,
    reason,
    actor: getCurrentActor(),
  });
}
One command, executable by operators on-call. Containment within seconds.

Security review checklist

Periodically (quarterly is reasonable), walk through this checklist:
  • All tokens are stored encrypted at rest
  • All tokens are in a secrets manager, not application code or config
  • Token access is logged with reason in every code path
  • No tokens appear in logs, error traces, or monitoring dashboards
  • Per-customer encryption keys are used (not just a single master key)
  • Token rotation reminders are sent every 6 months
  • HTTPS-only enforced (no HTTP fallback)
  • PII storage minimized to what workflows actually use
  • Customer-initiated data deletion is supported and tested
  • Offboarding deletes credentials immediately; data on a documented retention
  • Audit logs cannot be modified or deleted by application code
  • Incident response runbook exists and has been rehearsed
  • Compliance requirements (GDPR, CCPA, etc.) are documented and met
A pass on this checklist means the integration meets basic security hygiene for B2B partner work. Going beyond — penetration testing, SOC 2 audits, etc. — depends on customer requirements.

Where to go next

Versioning and Backward Compatibility

The patterns for surviving API changes without breaking integrations.

Sync Architecture Patterns

The architectural patterns this security model fits into.

Build a Volunteer Self-Service Portal

The recipe that puts these security patterns to work in a customer-facing product.

Authentication

The reference page for VOMO’s auth model.
Last modified on May 22, 2026