# Audit readiness for SOC 2 (/docs/guides/soc2-audit-readiness)

> Set up continuous compliance checks, access certification campaigns, a verified and archived audit chain, change control, and a signed evidence pack that map Better IAM features to SOC 2 access-control criteria.



A SOC 2 audit asks the same identity questions every year: is a second factor enforced, do leavers lose access, is
access reviewed, who can administer the system, can anyone edit the logs, and how are access changes controlled?
In this guide you set up one organization so those answers are produced continuously and handed to the auditor as
evidence they can verify themselves.

You will:

* adopt the built-in SOC 2 control set and evaluate it every day;
* run quarterly access certification campaigns with manager review and auto-close;
* verify, archive, and export the tamper-evident audit chain;
* control access changes with impact previews, enforced
  access invariants, and configuration as code in CI;
* record accepted risks as time-boxed exceptions approved by a second person;
* export a signed evidence pack that an auditor verifies offline.

> **Evidence, not certification.** 
  Better IAM does not make an organization SOC 2 compliant, and nothing in it is certified. Its checks are mapped to
  SOC 2 criteria as evidence for the identity part of those criteria. The same criteria also cover systems,
  processes, and documents Better IAM never sees, and your auditor assesses the whole.

## Prerequisites [#prerequisites]

* A Better IAM instance exported as `iam` from `src/iam.ts`, the CLI configuration `better-iam.config.mjs`, and an
  organization tenant. See the [Quickstart](/docs/quickstart).
* An administrator credential for the organization holding `iam:compliance:manage`, `iam:compliance:evaluate`,
  `iam:compliance:read`, `iam:certifications:manage`, `iam:certifications:read`, `iam:invariants:manage`,
  `iam:invariants:read`, `iam:roles:update`, `iam:audit:read`, `iam:analysis:read`, `iam:policies:simulate`,
  `iam:webhooks:create`, and `iam:tenants:update`, from a recent sign-in (setting policies and closing campaigns
  require recent authentication).
* A second administrator, because exceptions must be approved by someone other than the person who proposed them.
* A scheduler that can run CLI commands (cron, Kubernetes CronJobs, or your platform's equivalent). See
  [Scheduled jobs](/docs/operations/jobs).
* Email delivery (`authentication.sendEmail`) if reviewers should be emailed their certification items.

      ### Map the criteria to features [#map-the-criteria-to-features]

    Start with the map your auditor will ask for. The built-in SOC 2 mapping covers four criteria; change management has
    no built-in check, so its evidence comes from your change process and the audit log.

    | SOC 2 criterion                                             | Built-in checks (`checkId`)                                                               | Better IAM features                                                | Evidence                                                                        |
    | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
    | CC6.1 Logical access security                               | `mfa-enforced`, `mfa-coverage`, `password-length`, `session-lifetime`, `api-key-lifetime` | Tenant authentication policy, MFA, API key expiry                  | Control results in the evidence pack                                            |
    | CC6.2 Registration and deregistration of users              | `leaver-access`, `inactive-accounts`                                                      | Offboarding, identity expiry, SCIM deprovisioning                  | Control results, `identity:offboard` audit events                               |
    | CC6.3 Role-based access, least privilege, and access review | `access-reviews`, `privileged-access`, `separation-of-duties`, `stale-api-keys`           | Certifications, just-in-time elevation, separation-of-duties rules | Closed campaigns, control results, activation audit events                      |
    | CC7.2 Monitoring of system components                       | `audit-integrity`                                                                         | Audit hash chain, archive, webhooks                                | `audit-verify` output, archived chain, evidence pack chain head                 |
    | Change management (CC8.1)                                   | None                                                                                      | Configuration as code, impact preview, enforced invariants         | Reviewed pull requests, `config:apply` audit events, `check-invariants` CI logs |

    The checks are exported from `@better-iam/server` as `complianceChecks` and the mappings as
    `complianceFrameworks`, and `compliance.catalog` lists every check with its parameters.
  
      ### Close the obvious gaps first [#close-the-obvious-gaps-first]

    Two calls show where the organization stands before an auditor sees it. Fix what they report, then enforce the
    basics with the tenant authentication policy.

    ```ts title="scripts/soc2-baseline.ts"
    import { iam } from '../src/iam.js';

    const admin = { token: process.env.ADMIN_TOKEN! };
    const tenantId = process.env.TENANT_ID!;

    // Risky configuration: unconditional admin policies, owners without MFA, standing admin access, stale keys.
    const { summary, findings } = await iam.api.analysis.findings(admin, { tenantId, dormantDays: 90 });
    console.log(summary, findings.filter((finding) => finding.severity === 'high'));

    // CC6.1 basics: MFA for everyone, bounded sessions, longer passwords.
    await iam.api.tenants.setAuthPolicy(admin, {
      tenantId,
      authPolicy: {
        requireMfa: true,
        sessionLifetimeMs: 7 * 86_400_000,
        sessionIdleTimeoutMs: 8 * 3_600_000,
        minPasswordLength: 14,
      },
    });
    ```

    **Routes:** `POST /analysis/findings`, `POST /tenants/setAuthPolicy`

    Existing sessions are re-validated against the new policy on their next use, so requiring MFA immediately locks out
    every session that did not complete it, yours included. Make sure administrators have a factor enrolled and signed
    in with it before you set the policy. For privileged roles, replace standing bindings with eligible ones as described in
    [Just-in-time admin access](/docs/guides/just-in-time-admin-access); the `privileged-access` check counts everyone who
    can administer the organization by any path.
  
      ### Adopt the SOC 2 controls and evaluate them [#adopt-the-soc-2-controls-and-evaluate-them]

    `compliance.adoptFramework` creates one control per check the framework uses, keyed by the check's ID and mapped as
    `soc2:CC6.1` and so on. Tune a control's parameters to your written policy, or add your own controls for internal
    policy IDs.

    ```ts title="scripts/soc2-controls.ts"
    import { iam } from '../src/iam.js';

    const admin = { token: process.env.ADMIN_TOKEN! };
    const tenantId = process.env.TENANT_ID!;

    await iam.api.compliance.adoptFramework(admin, { tenantId, framework: 'soc2' });

    // Your policy says 60 days of inactivity, not the default 90.
    await iam.api.compliance.createControl(admin, {
      tenantId,
      key: 'inactive-60',
      name: 'Accounts unused for 60 days are disabled',
      checkId: 'inactive-accounts',
      params: { days: 60 },
      mappings: ['soc2:CC6.2', 'internal:ACCESS-4'],
    });

    const { run, results } = await iam.api.compliance.evaluate(admin, { tenantId });
    console.log(run.counts, run.digest);
    for (const result of results) console.log(result.controlKey, result.status, result.summary);

    const [soc2] = await iam.api.compliance.status(admin, { tenantId, framework: 'soc2' });
    console.log(`${soc2.passing}/${soc2.covered} covered requirements pass (${soc2.total} total)`);
    ```

    **Routes:** `POST /compliance/adoptFramework`, `POST /compliance/createControl`, `POST /compliance/evaluate`,
    `POST /compliance/status`

    * `evaluate` runs every enabled control at most once a minute per organization (`RATE_LIMITED` otherwise). A check
      that cannot run fails its control rather than passing by omission.
    * Each run stores a SHA-256 `digest` of its results and records it in the audit chain as `compliance:evaluate`.
      A control that starts or stops failing records `compliance:control:fail` or `compliance:control:recover`.
    * Results older than three days are `stale` and count as not evaluated, so a scheduler that stopped cannot leave
      old passes standing. Step 7 schedules the daily evaluation.
  
      ### Run access reviews [#run-access-reviews]

    The `access-reviews` check passes when a review that reviewers mostly decided closed within the interval (90 days
    by default) and covered the roles people hold. A certification campaign is that review. Open one per quarter, send
    each person's items to their manager, and let it close itself when due.

    ```ts title="scripts/quarterly-review.ts"
    import { iam } from '../src/iam.js';

    const admin = { token: process.env.ADMIN_TOKEN! };
    const tenantId = process.env.TENANT_ID!;

    const campaign = await iam.api.certifications.create(admin, {
      tenantId,
      name: 'Q4 access review',
      reviewerMode: 'manager',
      reviewerIds: [process.env.SECURITY_LEAD_ID!], // items without an active manager go here
      dueAt: Date.parse('2026-12-15T17:00:00Z'),
      autoClose: true,
      undecided: 'revoke', // silence means removal
    });
    console.log(campaign.id, campaign.items, 'bindings to review');

    // Put usage evidence next to every item before reviewers start.
    const { recommendations } = await iam.api.roleMining.reviewRecommendations(admin, {
      tenantId,
      campaignId: campaign.id,
      unusedDays: 90,
    });
    console.log(recommendations.filter((item) => item.recommendation === 'revoke').length, 'suggested revocations');
    ```

    **Routes:** `POST /certifications/create`, `POST /roleMining/reviewRecommendations`

    Managers review from their own session without an administrator role:

          **Client:**

        ```ts title="src/my-reviews.ts"
        import { createIamClient } from 'better-iam/client';
        import type { iam } from './iam.js';

        const client = createIamClient<typeof iam>();

        export const myCampaigns = (tenantId: string) => client.certifications.listMine({ tenantId });

        export const submit = (tenantId: string, campaignId: string) =>
          client.certifications.review({
            tenantId,
            campaignId,
            decisions: [
              { itemId: 'item-1', decision: 'keep' },
              { itemId: 'item-2', decision: 'revoke', note: 'Moved to finance in July' },
            ],
          });
        ```
      
      **Server:**

        ```ts title="src/my-reviews-server.ts"
        import { iam } from './iam.js';

        export async function submit(request: Request, tenantId: string, campaignId: string) {
          return iam.api.certifications.review(
            { headers: request.headers },
            { tenantId, campaignId, decisions: [{ itemId: 'item-1', decision: 'keep' }] },
          );
        }
        ```
          
    **Routes:** `POST /certifications/listMine`, `POST /certifications/review`

    A few days before `dueAt`, call `certifications.remind` to email reviewers who still have undecided items (it needs
    email delivery and is not sent automatically). Nobody decides
    on their own access (`SELF_REVIEW`). Closing removes revoked bindings under the closer's grant authority, records
    an outcome on every item, and keeps the campaign as evidence. With `autoClose`, the scheduled
    `close-certifications` job closes it when due and audits it as `certification:auto-close`.
  
      ### Protect the audit trail [#protect-the-audit-trail]

    An auditor relies on the audit log only if nobody could quietly change it. Each tenant's log is a hash chain, and
    the `audit-integrity` check verifies it on every evaluation. Keep an independent copy outside the database so even
    someone with database access cannot rewrite history unnoticed.

    ```ts title="src/iam.ts"
    import { betterIam } from 'better-iam';
    import { postgresAdapter } from 'better-iam/adapter-postgres';
    import { createJsonlAuditArchive } from 'better-iam/server';

    export const iam = betterIam({
      database: postgresAdapter({ connectionString: process.env.DATABASE_URL! }),
      secret: process.env.BETTER_IAM_SECRET,
      baseURL: process.env.BETTER_IAM_BASE_URL,
      // Write-once JSON Lines files per verified batch; replace with your own sink for object storage.
      auditArchive: createJsonlAuditArchive({ directory: '/var/lib/better-iam/audit' }),
    });
    ```

    Verify the stored chain and the archive independently, and export a copy for the audit period:

    ```bash
    better-iam audit-verify --config better-iam.config.mjs --tenant "$TENANT_ID"
    better-iam audit-verify-archive --directory /var/lib/better-iam/audit --tenant "$TENANT_ID"
    better-iam audit-export --config better-iam.config.mjs --tenant "$TENANT_ID" --output audit-2026.jsonl
    ```

    Through the API, `audit.verify` returns `{ valid, checked, failure? }` and `audit.export` pages the chain as JSON
    Lines:

    ```ts title="scripts/audit-check.ts"
    import { iam } from '../src/iam.js';

    const admin = { token: process.env.ADMIN_TOKEN! };
    const tenantId = process.env.TENANT_ID!;

    const status = await iam.api.audit.verify(admin, { tenantId });
    if (!status.valid) throw new Error(`Audit chain broken at ${status.failure?.sequence}: ${status.failure?.reason}`);
    console.log(`${status.checked} events verified; head`, status.head);
    ```

    **Routes:** `POST /audit/verify`, `POST /audit/export`

    Auditors can check an exported file anywhere with `verifyAuditChain` from `better-iam`, which uses Web Crypto and
    needs no access to your deployment. Prune old events only with `audit-prune`, which never deletes events the
    archive does not hold yet and leaves a checkpoint so verification continues. See
    [Audit chain](/docs/concepts/events/audit-chain).
  
      ### Control access changes [#control-access-changes]

    Change management evidence answers "how do you know an access change was intended and reviewed?". Three tools
    cover it.

    **Preview before editing.** `impact.preview` makes the change in a transaction it always rolls back and lists who
    gains and loses which actions:

    ```ts title="scripts/preview-role-change.ts"
    import { iam } from '../src/iam.js';

    const admin = { token: process.env.ADMIN_TOKEN! };
    const tenantId = process.env.TENANT_ID!;

    const preview = await iam.api.impact.preview(admin, {
      tenantId,
      change: { role: { roleId: process.env.ROLE_ID!, permissions: ['payments:read', 'payments:approve'] } },
      resources: [{ type: 'ledger', id: 'main' }],
    });
    console.log(preview.identities, preview.invariants.broken);
    ```

    **Route:** `POST /impact/preview`

    **Enforce lines that must never be crossed.** An enforced invariant refuses any operation that would add a
    violation, with `INVARIANT_VIOLATION` (409), and rolls it back. The action and resource must exist in your catalog;
    `payments:approve` on `ledger/main` is an example.

    ```ts title="scripts/invariants.ts"
    import { iam } from '../src/iam.js';

    const admin = { token: process.env.ADMIN_TOKEN! };
    const tenantId = process.env.TENANT_ID!;

    await iam.api.invariants.create(admin, {
      tenantId,
      name: 'Contractors never approve payments',
      subject: { groupId: process.env.CONTRACTORS_GROUP_ID! },
      action: 'payments:approve',
      resource: { type: 'ledger', id: 'main' },
      expect: 'deny',
      mode: 'enforce',
    });
    ```

    **Route:** `POST /invariants/create`

    **Review access as code.** Keep the tenant's roles, policies, groups, group bindings, and invariants in a reviewed
    file (`config-export` writes the current state) and apply
    it from CI, so every change has a pull request and a `config:apply` audit event. Use a scoped API key of a service
    account in `BETTER_IAM_TOKEN`.

    ```yaml title=".github/workflows/iam.yml"
    name: IAM configuration
    on:
      push:
        branches: [main]
      schedule:
        - cron: '0 6 * * *'
    jobs:
      apply:
        runs-on: ubuntu-latest
        env:
          BETTER_IAM_TOKEN: ${{ secrets.BETTER_IAM_TOKEN }}
          TENANT_ID: ${{ vars.TENANT_ID }}
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
            with:
              node-version: 22
          - run: npm ci
          - name: Apply the reviewed configuration
            if: github.event_name == 'push'
            run: npx better-iam config-apply --config better-iam.config.mjs --tenant "$TENANT_ID" --input tenant.json
          - name: Production matches the reviewed file
            run: npx better-iam config-plan --config better-iam.config.mjs --tenant "$TENANT_ID" --input tenant.json --fail-on-drift
          - name: Guardrails hold
            run: npx better-iam check-invariants --config better-iam.config.mjs --tenant "$TENANT_ID" --fail-on-broken
    ```

    `config-plan --fail-on-drift` exits with `CONFIG_DRIFT` when someone edited production by hand, and
    `check-invariants --fail-on-broken` exits with `INVARIANTS_BROKEN` when a guardrail is broken. See
    [Configuration as code](/docs/features/privileged-access/config-as-code).
  
      ### Schedule the recurring jobs [#schedule-the-recurring-jobs]

    Evidence is only continuous if the jobs run. Each command below is a deployment operation that reads the
    configuration and needs no credential.

    | Job                     | Command                                                                     | Schedule          | Why                                                                                     |
    | ----------------------- | --------------------------------------------------------------------------- | ----------------- | --------------------------------------------------------------------------------------- |
    | Evaluate controls       | `better-iam compliance-evaluate --config better-iam.config.mjs`             | Daily             | Results older than three days count as not evaluated                                    |
    | Close due campaigns     | `better-iam close-certifications --config better-iam.config.mjs`            | Daily             | Applies auto-closing campaigns once `dueAt` passes                                      |
    | Watch invariants        | `better-iam monitor-invariants --config better-iam.config.mjs`              | Hourly            | Records `invariant:broken` and `invariant:restored` for changes made outside operations |
    | Archive the audit chain | `better-iam audit-archive --config better-iam.config.mjs`                   | Every few minutes | Copies new events to the archive after verifying each batch                             |
    | Verify the audit chain  | `better-iam audit-verify --config better-iam.config.mjs --tenant TENANT_ID` | Daily             | Exits non-zero with `AUDIT_CHAIN_BROKEN` when a stored event was altered                |

    In process, the same jobs are `iam.compliance.evaluateAll()`, `iam.closeOverdueCertifications()`,
    `iam.checkInvariants()`, and `iam.archiveAudit()`. Route regressions to your alerting with a webhook:

    ```ts title="scripts/compliance-alerts.ts"
    import { iam } from '../src/iam.js';

    const admin = { token: process.env.ADMIN_TOKEN! };

    await iam.api.webhooks.create(admin, {
      tenantId: process.env.TENANT_ID!,
      url: 'https://alerts.example.com/iam',
      events: ['compliance:control:fail', 'compliance:control:recover', 'invariant:*'],
    });
    ```

    **Route:** `POST /webhooks/create`
  
      ### Record accepted risks as exceptions [#record-accepted-risks-as-exceptions]

    Some findings are accepted for a while: a person on leave, a service account with a compensating control. An
    exception names the control, the finding's subject, a reason, and an expiry at most a year away. It counts only
    after a second person approves it, and there are no permanent exceptions.

    ```ts title="scripts/exception.ts"
    import { iam } from '../src/iam.js';

    const proposer = { token: process.env.ADMIN_TOKEN! };
    const approver = { token: process.env.SECOND_ADMIN_TOKEN! };
    const tenantId = process.env.TENANT_ID!;

    const exception = await iam.api.compliance.createException(proposer, {
      tenantId,
      controlKey: 'inactive-60',
      subject: `identity:${process.env.ON_LEAVE_ID}`,
      reason: 'Parental leave until 2027-01-31; account disabled for sign-in by HR process',
      expiresAt: Date.parse('2027-01-31T00:00:00Z'),
    });
    await iam.api.compliance.approveException(approver, { tenantId, exceptionId: exception.id });
    ```

    **Routes:** `POST /compliance/createException`, `POST /compliance/approveException`

    Nobody may propose or approve an exception for a finding about themselves, and `audit-integrity` findings cannot be
    excepted. Revoked and expired exceptions stay in `listExceptions` and in evidence packs as history.
  
      ### Export the evidence pack [#export-the-evidence-pack]

    At audit time, export a signed pack for the SOC 2 framework and the public keys that verify it. The pack holds the
    controls with their parameters, mappings, latest results and findings, every exception with its approval history,
    the latest 30 runs with their digests, and the audit chain head.

    ```ts title="scripts/export-evidence.ts"
    import { writeFile } from 'node:fs/promises';
    import { verifyEvidencePack } from 'better-iam/server';
    import { iam } from '../src/iam.js';

    const admin = { token: process.env.ADMIN_TOKEN! };
    const tenantId = process.env.TENANT_ID!;

    const pack = await iam.api.compliance.exportEvidence(admin, { tenantId, framework: 'soc2' });
    const jwks = await iam.api.compliance.evidenceKeys(admin, { tenantId });
    await writeFile('soc2-evidence.json', JSON.stringify(pack, null, 2));
    await writeFile('soc2-evidence-keys.json', JSON.stringify(jwks, null, 2));

    // What the auditor runs, offline, with only the two files:
    const { valid, reason } = verifyEvidencePack(pack, jwks);
    console.log(valid ? 'Evidence pack verifies' : `Evidence pack rejected: ${reason}`);
    ```

    **Routes:** `POST /compliance/exportEvidence`, `POST /compliance/evidenceKeys`

    The pack carries a SHA-256 `digest` and an Ed25519 `signature` by a key derived from the deployment secret, and the
    digest is recorded in the audit chain when the pack is exported. `evidenceKeys` lists the current key and those of
    `previousSecrets`, so packs keep verifying after a secret rotation. Findings name people only when the exporting
    caller may read the directory: handle packs as personal data.
  
## Verify it works [#verify-it-works]

1. `compliance.status` for `soc2` reports `covered` equal to `total` (4) and lists each requirement with its
   controls. Any `not-evaluated` status means the daily job is not running.
2. `compliance.listRuns` shows one run per day, each with a `digest`, and `audit.list` with
   `action: 'compliance:evaluate'` shows the same digests in the audit chain.
3. `certifications.list` with `status: 'closed'` shows last quarter's campaign, and `certifications.get` reports its
   `progress` with most items decided.
4. `better-iam audit-verify` and `better-iam audit-verify-archive` both exit with status 0, and the archive's last
   sequence matches the `head` that `audit.verify` returns.
5. Try to bind a contractor to the payments approver role: the call fails with `INVARIANT_VIOLATION` and nothing is
   saved.
6. `verifyEvidencePack` returns `valid: true` for the exported pack, and `false` after you change one character in
   the file.

## Troubleshooting [#troubleshooting]

  #### A requirement shows not-evaluated

    Its control has no result from the last three days, or was never evaluated. Check that `compliance-evaluate` runs
    daily and look at its output: organizations whose evaluation failed are listed under `failed` without stopping the
    others. A disabled control shows `disabled` and also keeps the requirement from passing.
  
  #### RATE_LIMITED from compliance.evaluate

    Manual evaluation runs at most once a minute per organization. Wait and retry; the scheduled job is not affected
    by manual runs.
  
  #### access-reviews fails although a campaign exists

    The check needs a campaign that closed within `intervalDays` (90), in which at least `minDecidedPercent` (80) of
    items were decided, covering `minCoveragePercent` (100) of the roles people hold, and no overdue open campaign. An
    open campaign does not count until it is closed; a campaign restricted with `roleIds` may not cover every role.
  
  #### SELF_REVIEW when a reviewer decides

    Nobody decides on their own access, directly or through a group. With `certifications.get` and `mine: true`, the
    caller's own items are left out; another reviewer or the security lead decides them.
  
  #### AUDIT_CHAIN_BROKEN

    `audit-verify` found a `sequence-gap`, `previous-hash-mismatch`, `hash-mismatch`, `head-mismatch`, `missing-prefix`,
    or `unchained` failure at the reported sequence: a stored event was deleted, reordered, inserted, or edited. Treat it as a security incident, compare with the
    archive, and do not prune until it is understood. The `audit-integrity` control fails and cannot be excepted.
  
  #### INVARIANT_VIOLATION blocks a legitimate change

    The change would add a violation of an enforced invariant. Relax or delete the invariant first in a separate,
    reviewed change, then make the access change. Violations that already existed do not block unrelated work.
  
  #### An exception does not change the control's status

    Exceptions count only once approved by a second person and until they expire. Check `listExceptions` for
    `status: 'pending'`, and that `subject` matches the finding exactly (for example `identity:ID`).
  
## Next steps [#next-steps]

  - [Compliance center](/docs/features/governance/compliance): Every check, its parameters, and the ISO 27001, NIST 800-53, and GDPR mappings.

  - [Certifications](/docs/features/governance/certifications): Campaign options, manager reviews, recommendations, and auto-close.

  - [Audit chain](/docs/concepts/events/audit-chain): How the chain works, archiving, and pruning with checkpoints.

  - [Change safety](/docs/features/governance/change-safety): Impact previews and access invariants in detail.
