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
iamfromsrc/iam.ts, the CLI configurationbetter-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, andiam: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 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
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.
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.
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
evaluateruns every enabled control at most once a minute per organization (RATE_LIMITEDotherwise). A check that cannot run fails its control rather than passing by omission.- Each run stores a SHA-256
digestof its results and records it in the audit chain ascompliance:evaluate. A control that starts or stops failing recordscompliance:control:failorcompliance:control:recover. - Results older than three days are
staleand 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.
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:
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.
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.jsonlThrough the API, audit.verify returns { valid, checked, failure? } and audit.export pages the chain as JSON
Lines:
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:
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.
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.
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-brokenconfig-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.
| 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:
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.
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.
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
compliance.statusforsoc2reportscoveredequal tototal(4) and lists each requirement with its controls. Anynot-evaluatedstatus means the daily job is not running.compliance.listRunsshows one run per day, each with adigest, andaudit.listwithaction: 'compliance:evaluate'shows the same digests in the audit chain.certifications.listwithstatus: 'closed'shows last quarter's campaign, andcertifications.getreports itsprogresswith most items decided.better-iam audit-verifyandbetter-iam audit-verify-archiveboth exit with status 0, and the archive's last sequence matches theheadthataudit.verifyreturns.- Try to bind a contractor to the payments approver role: the call fails with
INVARIANT_VIOLATIONand nothing is saved. verifyEvidencePackreturnsvalid: truefor the exported pack, andfalseafter you change one character in the file.
Troubleshooting
Next steps
Every check, its parameters, and the ISO 27001, NIST 800-53, and GDPR mappings.
CertificationsCampaign options, manager reviews, recommendations, and auto-close.
Audit chainHow the chain works, archiving, and pruning with checkpoints.
Change safetyImpact previews and access invariants in detail.
Better IAM is created by Sean Filimon
Last updated
Just-in-time admin access
Replace standing administrator bindings with eligible bindings that members activate for a bounded time, with justification, MFA, approval by a named group, audit events, and revocation.
Usage-based billing
Meter API usage per organization, price it with a graduated rate card and a subscription plan, enforce budgets, close the month into invoices, and record payments from a payment processor webhook.