> ## Documentation Index
> Fetch the complete documentation index at: https://docs.virtuous.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Security & Credential Management

> The security patterns for Volunteer integrations — token lifecycle, secure storage, scope minimization, audit logging, customer offboarding, and the practices that protect both your integration and your customers' data.

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](/volunteer/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-pattern                                       | Why it's wrong                      | Better                             |
| -------------------------------------------------- | ----------------------------------- | ---------------------------------- |
| Token in environment variable on production        | Anyone with deploy access sees them | Secrets manager with audit logging |
| Token in config file checked into git              | Permanent leak in source history    | `.gitignore` + secrets manager     |
| Token in CI/CD logs                                | Logs are often broadly accessible   | Mask in logs; never echo           |
| Token sent in customer support email               | Email is not a secure channel       | Secure customer portal upload      |
| Token in screenshots / docs                        | Permanent visual leak               | Redact in any visual capture       |
| Same token across multiple environments (dev/prod) | A dev leak compromises production   | Separate 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:

```mermaid theme={null}
graph TD
  Token["Customer-issued token"]
  Encryption["Encryption at rest"]
  Secrets["Secrets manager<br/>(AWS Secrets Manager, Vault, etc.)"]
  Access["Access control<br/>(IAM, RBAC)"]
  Audit["Audit logging"]
  Rotation["Rotation policy"]
  Egress["Egress restrictions"]

  Token --> Encryption
  Encryption --> Secrets
  Secrets --> Access
  Access --> Audit
  Audit --> Rotation
  Rotation --> Egress
```

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

| Layer                   | Purpose                                                                   |
| ----------------------- | ------------------------------------------------------------------------- |
| **Encryption at rest**  | Tokens encrypted in the database; only decrypted in memory when needed    |
| **Secrets manager**     | Tokens stored in a purpose-built service (not your application DB)        |
| **Access control**      | Only specific service accounts can read decrypted tokens                  |
| **Audit logging**       | Every access to a token is logged with who, when, why                     |
| **Rotation**            | Tokens rotated on a schedule; old tokens expire                           |
| **Egress restrictions** | Network 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 JavaScript theme={null}
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 JavaScript theme={null}
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 JavaScript theme={null}
// ❌ 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 JavaScript theme={null}
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.

***

## Principle 6: customer-side handoff is the weak link

The path from customer → partner is where most token compromises happen:

| Handoff method                                    | Risk                                             |
| ------------------------------------------------- | ------------------------------------------------ |
| Customer pastes token into a partner web form     | Token transits over TLS; partner stores it       |
| Customer emails token to partner support          | Email is not encrypted; recipients vary          |
| Customer texts/IMs token                          | Messaging is often archived; clients are diverse |
| Customer types token into a screen-shared session | Visible in screen recording                      |
| Customer commits token to a shared git repo       | Catastrophic — 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 JavaScript theme={null}
// 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.

```mermaid theme={null}
graph LR
  Customer["Customer admin"]
  Old["Old token (active)"]
  New["New token (generated)"]
  Partner["Partner integration"]

  Customer -->|"generate new"| New
  Customer -->|"update via form"| Partner
  Partner -->|"verify new works"| Old
  Customer -->|"revoke old"| Old
```

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 JavaScript theme={null}
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:

| Event                                 | What to log                                                                   |
| ------------------------------------- | ----------------------------------------------------------------------------- |
| Token storage                         | `customer_id`, `actor`, timestamp; not the token itself                       |
| Token access                          | `customer_id`, `actor`, `reason`, timestamp                                   |
| Token rotation                        | `customer_id`, `actor`, timestamp; old and new fingerprints (not full tokens) |
| Failed authentication (401 from VOMO) | `customer_id`, request context, timestamp                                     |
| Customer onboarding                   | `customer_id`, who initiated, configuration set                               |
| Customer offboarding                  | `customer_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 type                                 | Retention                            |
| ------------------------------------------ | ------------------------------------ |
| Security events (auth failures, rotations) | At least 1 year                      |
| Operational events (polling, processing)   | 30-90 days                           |
| Per-record access logs                     | 30-90 days                           |
| Customer onboarding/offboarding            | Permanent (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

| Practice                           | What it does                                         |
| ---------------------------------- | ---------------------------------------------------- |
| Encryption in transit (HTTPS only) | Prevents network interception                        |
| Encryption at rest                 | Protects against database breaches                   |
| Minimum data retention             | Reduces blast radius                                 |
| Per-customer data isolation        | Prevents cross-customer leaks                        |
| Customer-initiated data deletion   | Complies with right-to-be-forgotten requests         |
| Access logging                     | Enables audit of who saw what                        |
| Secure deletion                    | Ensures deleted means deleted (not just marked)      |
| Subprocessor disclosure            | Document 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 JavaScript theme={null}
// ❌ 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 JavaScript theme={null}
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 JavaScript theme={null}
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

```mermaid theme={null}
graph LR
  Detect["Detect<br/>(alert, report)"]
  Contain["Contain<br/>(disable, revoke)"]
  Investigate["Investigate<br/>(scope, impact)"]
  Notify["Notify<br/>(customer, internal)"]
  Recover["Recover<br/>(rotate, restore)"]
  Document["Document<br/>(lessons)"]

  Detect --> Contain
  Contain --> Investigate
  Investigate --> Notify
  Notify --> Recover
  Recover --> Document
```

| Step        | Action                                                                                                 |
| ----------- | ------------------------------------------------------------------------------------------------------ |
| Detect      | Alert fires, audit log review surfaces concern, customer reports issue                                 |
| Contain     | Immediately disable the affected customer's integration; revoke their token via VOMO admin if possible |
| Investigate | What was accessed? What was the scope? When did it start?                                              |
| Notify      | Customer first (always), then internal stakeholders, then any regulatory bodies if required            |
| Recover     | Customer issues new token; partner stores; integration resumes                                         |
| Document    | What 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 JavaScript theme={null}
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

<CardGroup cols={2}>
  <Card title="Versioning and Backward Compatibility" icon="code-branch" href="/volunteer/best-practices/versioning-and-backward-compatibility">
    The patterns for surviving API changes without breaking integrations.
  </Card>

  <Card title="Sync Architecture Patterns" icon="route" href="/volunteer/best-practices/sync-architecture-patterns">
    The architectural patterns this security model fits into.
  </Card>

  <Card title="Build a Volunteer Self-Service Portal" icon="window" href="/volunteer/recipes/build-a-volunteer-self-service-portal">
    The recipe that puts these security patterns to work in a customer-facing product.
  </Card>

  <Card title="Authentication" icon="key" href="/volunteer/authentication">
    The reference page for VOMO's auth model.
  </Card>
</CardGroup>
