BetterIAM

Audit readiness for SOC 2

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 with manager review and auto-close;
  • verify, archive, and export the tamper-evident ;
  • control access changes with , enforced , 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

  • 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.
  • 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.
  • Email delivery (authentication.sendEmail) if reviewers should be emailed their certification items.

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 criterionBuilt-in checks (checkId)Better IAM featuresEvidence
CC6.1 Logical access securitymfa-enforced, mfa-coverage, password-length, session-lifetime, api-key-lifetimeTenant authentication policy, MFA, API key expiryControl results in the evidence pack
CC6.2 Registration and deregistration of usersleaver-access, inactive-accountsOffboarding, identity expiry, SCIM deprovisioningControl results, identity:offboard audit events
CC6.3 Role-based access, least privilege, and access reviewaccess-reviews, privileged-access, separation-of-duties, stale-api-keysCertifications, just-in-time elevation, separation-of-duties rulesClosed campaigns, control results, activation audit events
CC7.2 Monitoring of system componentsaudit-integrityAudit hash chain, archive, webhooksaudit-verify output, archived chain, evidence pack chain head
Change management (CC8.1)NoneConfiguration as code, impact preview, enforced invariantsReviewed 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

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.

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; the privileged-access check counts everyone who can administer the organization by any path.

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.

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

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.

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:

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' },
    ],
  });

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

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.

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:

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:

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.

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:

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.

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.

.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.

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.

JobCommandScheduleWhy
Evaluate controlsbetter-iam compliance-evaluate --config better-iam.config.mjsDailyResults older than three days count as not evaluated
Close due campaignsbetter-iam close-certifications --config better-iam.config.mjsDailyApplies auto-closing campaigns once dueAt passes
Watch invariantsbetter-iam monitor-invariants --config better-iam.config.mjsHourlyRecords invariant:broken and invariant:restored for changes made outside operations
Archive the audit chainbetter-iam audit-archive --config better-iam.config.mjsEvery few minutesCopies new events to the archive after verifying each batch
Verify the audit chainbetter-iam audit-verify --config better-iam.config.mjs --tenant TENANT_IDDailyExits 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:

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

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.

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

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.

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

  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

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page