SOC 2 User Access Reviews: What Auditors Test and the Evidence to Keep

An access review is one of the most common SOC 2 controls, and one of the most common places evidence falls apart. The review itself is simple. Proving that you did it, completely, on schedule, and acted on what you found, is where teams lose time in the audit.
This guide covers what the Trust Services Criteria actually say, what an auditor will ask to see, how to run the review, and a small script that does the tedious matching for you.
What is a user access review?
A user access review is a periodic check that every account in a system still belongs to someone who needs it, with the right level of access. Someone with the authority to decide goes through a complete list of accounts, marks each one keep, change or remove, and the changes are made and recorded. For SOC 2, the record of that work is the evidence.
What does SOC 2 actually require?
SOC 2 reports are measured against the AICPA's 2017 Trust Services Criteria. Two criteria in the logical access section cover this control.
CC6.2: "Prior to issuing system credentials and granting system access, the entity registers and authorizes new internal and external users whose access is administered by the entity. For those users whose access is administered by the entity, user system credentials are removed when user access is no longer authorized."
CC6.3: "The entity authorizes, modifies, or removes access to data, software, functions, and other protected information assets based on roles, responsibilities, or the system design and changes, giving consideration to the concepts of least privilege and segregation of duties, to meet the entity's objectives."
The periodic review appears in the points of focus under both criteria:
- Under CC6.2, Reviews Appropriateness of Access Credentials: "The appropriateness of access credentials is reviewed on a periodic basis for unnecessary and inappropriate individuals with credentials."
- Under CC6.3, Reviews Access Roles and Rules: "The appropriateness of access roles and access rules is reviewed on a periodic basis for unnecessary and inappropriate individuals with access and access rules are modified as appropriate."
Three things follow from the text, and they save a lot of arguing:
- SOC 2 doesn't set a frequency. The criteria say "on a periodic basis". Quarterly is a common choice, and many teams review administrator and other privileged accounts more often. You choose the frequency and write it into your control description.
- Once you've written it down, that's what gets tested. In a Type 2 report the auditor checks the control operated as described throughout the audit period. A quarterly review done twice in a twelve-month period is an exception, however good those two reviews were.
- Points of focus aren't a checklist. The AICPA describes them as important characteristics of the criteria, and states that using the criteria "does not require an assessment of whether each point of focus is addressed." Auditors still use them to judge whether a control is well designed, which is why nearly every SOC 2 program has an access review.
What evidence will the auditor ask for?
| Evidence | Why it's asked for |
|---|---|
| The complete account list for each in-scope system, as exported, with the export date and who exported it | The review is only as good as its population. The auditor needs to see nothing was left out or filtered. |
| Who reviewed each system, and when | Shows the review happened on schedule, by someone with the authority to decide |
| A decision for every account: keep, change or remove, with a reason for anything unusual | Shows each account was actually looked at, not approved in bulk |
| Proof that changes were made: a ticket or change record for each removal or change, with its completion date | A decision to remove access that never happened is still an open finding |
| A justification for each privileged account | Least privilege is part of CC6.3 |
| For a Type 2 report, the above for every review in the audit period | The control is tested across the whole period, not at one point in time |
Expect the removal side to be tested separately as well. Auditors commonly select people who left during the period and check when their access was removed in each system. A periodic review is what catches the leaver your offboarding process missed, so the two controls support each other.
How to run a quarterly access review
- List the in-scope systems. Start from your SOC 2 system description. Typically: your identity provider, cloud administration consoles, production databases and servers, code repositories and deployment tools, and any application that stores customer data. Include systems with their own local accounts, not only the ones behind single sign-on.
- Export every account, on the same day. For each system, export all accounts, enabled and disabled, with the role, whether the account is privileged, whether it's enabled, and the last sign-in date. Save each file unchanged and note who exported it and when. Don't filter the export before saving it: the unfiltered file is your proof of a complete population.
- Export the HR roster. Everyone's work email, employment status, end date for anyone leaving, and manager. Contractors belong on it too. If they're managed somewhere else, merge that list in.
- Match and flag. Match accounts to people and flag the ones that need a decision: leavers still enabled, accounts with no matching person, accounts nobody has used in months, and privileged accounts. The script below does this.
- Send each owner their slice. The system owner or the person's manager decides keep, change or remove for each account. Nobody should approve their own access. Have a second person review the administrators' accounts.
- Make the changes and record them. Open a ticket for every change, make the change, and write the ticket number next to the account in the worksheet.
- Sign off and file. File the completed worksheet, the raw exports and the tickets together, with the reviewer's name and the date. That folder is the evidence for the quarter.
A script that flags the accounts that need attention
Matching a few hundred accounts across several systems by hand is where reviews stall. This script takes your HR roster and a combined account export, and writes a review worksheet with the risky accounts at the top and empty columns for the reviewer's decision.
It needs Node.js 18 or later and nothing else. It reads files you've already exported and never connects to any of your systems.
The input files
roster.csv, from HR. Required columns: email and status (active, on_leave or
terminated). Optional: name, end_date (YYYY-MM-DD) and manager.
email,name,status,end_date,manager
dana.lee@example.com,Dana Lee,active,,Sam Ortiz
priya.n@example.com,Priya N,terminated,2026-08-07,Sam Ortiz
jo.kim@example.com,"Kim, Jo",active,2026-09-12,Sam Ortiz
accounts.csv, one row per account, all systems combined. Required columns: system, account,
enabled and last_login (a date, or empty if the account has never signed in). Optional: role,
privileged, and email for systems whose account names aren't email addresses.
system,account,email,enabled,last_login,role,privileged
idp,dana.lee@example.com,,true,2026-09-11,user,false
idp,priya.n@example.com,,true,2026-09-13,user,false
crm,contractor@agency.com,,true,2026-06-01,admin,true
repo,dlee,dana.lee@example.com,true,,maintainer,false
exceptions.csv (optional), for approved service and shared accounts that will never be on the HR
roster. Required columns: system, account, owner and reason.
system,account,owner,reason
crm,svc-backup,Sam Ortiz,Nightly export service account
Run it
node access-review.mjs roster.csv accounts.csv exceptions.csv > review.csv
Add --stale-days=60 to change the unused-account threshold from the default of 90 days. A summary of
the flags goes to the screen, and the worksheet goes to review.csv.
What the flags mean
Only enabled accounts are flagged. Disabled accounts stay in the worksheet so the population is complete.
| Flag | Meaning | Usual decision |
|---|---|---|
LEFT_BUT_ENABLED | The person is terminated in HR, or their end date has passed | Remove today, and check how offboarding missed it |
NO_HR_RECORD | No matching person and no approved exception | Find the owner. Remove it if nobody claims it. |
NO_SIGN_IN_90D | Not used in longer than the threshold | Remove or disable, unless there's a documented reason |
NEVER_SIGNED_IN | Enabled but never used | Usually over-provisioned. Remove. |
LAST_LOGIN_UNREADABLE | The sign-in date couldn't be read | Fix the export, then re-run |
PRIVILEGED | Administrator or other elevated access | Confirm it's still needed, and record why |
The script
#!/usr/bin/env node
// access-review.mjs: build a user access review worksheet from exports.
// Usage: node access-review.mjs roster.csv accounts.csv [exceptions.csv] [--stale-days=90] > review.csv
// Node 18+, no dependencies.
import { readFileSync } from "node:fs";
const args = process.argv.slice(2);
const staleArg = args.find((a) => a.startsWith("--stale-days="));
const STALE_DAYS = staleArg ? Number(staleArg.split("=")[1]) : 90;
const [rosterFile, accountsFile, exceptionsFile] = args.filter((a) => !a.startsWith("--"));
if (!rosterFile || !accountsFile || !(STALE_DAYS > 0)) {
console.error("usage: node access-review.mjs roster.csv accounts.csv [exceptions.csv] [--stale-days=90]");
process.exit(2);
}
// Minimal RFC 4180 CSV reader: quoted fields, embedded commas, quotes and newlines.
function readCsv(file, required) {
const text = readFileSync(file, "utf8"); // a byte-order mark is removed by trim() below
const rows = [];
let row = [], field = "", quoted = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quoted) {
if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
else if (c === '"') quoted = false;
else field += c;
} else if (c === '"') quoted = true;
else if (c === ",") { row.push(field); field = ""; }
else if (c === "\n" || c === "\r") {
if (c === "\r" && text[i + 1] === "\n") i++;
row.push(field); rows.push(row); row = []; field = "";
} else field += c;
}
if (field !== "" || row.length) { row.push(field); rows.push(row); }
const [header, ...body] = rows.filter((r) => r.some((v) => v.trim() !== ""));
const keys = (header ?? []).map((h) => h.trim().toLowerCase());
const missing = required.filter((k) => !keys.includes(k));
if (missing.length) {
console.error(`${file}: missing column(s) ${missing.join(", ")}`);
process.exit(2);
}
return body.map((r) => Object.fromEntries(keys.map((k, i) => [k, (r[i] ?? "").trim()])));
}
const norm = (s) => String(s ?? "").trim().toLowerCase();
const yes = (s) => ["true", "yes", "y", "1", "enabled", "active"].includes(norm(s));
const today = new Date();
const daysSince = (iso) => (today - new Date(iso)) / 86_400_000;
const people = new Map(readCsv(rosterFile, ["email", "status"]).map((p) => [norm(p.email), p]));
const approved = new Map(
(exceptionsFile ? readCsv(exceptionsFile, ["system", "account", "owner", "reason"]) : []).map((e) => [`${norm(e.system)}|${norm(e.account)}`, e]),
);
const out = [];
for (const a of readCsv(accountsFile, ["system", "account", "enabled", "last_login"])) {
const enabled = yes(a.enabled);
const person = people.get(norm(a.email || a.account));
const exception = approved.get(`${norm(a.system)}|${norm(a.account)}`);
const flags = [];
if (enabled) {
const ended = person && (norm(person.status) === "terminated" || (person.end_date && daysSince(person.end_date) >= 1));
if (ended) flags.push("LEFT_BUT_ENABLED");
if (!person && !exception) flags.push("NO_HR_RECORD");
if (!a.last_login) flags.push("NEVER_SIGNED_IN");
else if (Number.isNaN(Date.parse(a.last_login))) flags.push("LAST_LOGIN_UNREADABLE");
else if (daysSince(a.last_login) > STALE_DAYS) flags.push(`NO_SIGN_IN_${STALE_DAYS}D`);
if (yes(a.privileged)) flags.push("PRIVILEGED");
}
out.push({
system: a.system, account: a.account, role: a.role, privileged: yes(a.privileged) ? "yes" : "no",
enabled: enabled ? "yes" : "no", last_login: a.last_login, person: person?.name ?? "",
hr_status: person?.status ?? "", manager: person?.manager ?? "",
exception: exception ? `${exception.owner}: ${exception.reason}` : "", flags: flags.join(" "),
decision: "", reviewer: "", reviewed_on: "", ticket: "",
});
}
out.sort((x, y) => (y.flags ? 1 : 0) - (x.flags ? 1 : 0) || x.system.localeCompare(y.system) || x.account.localeCompare(y.account));
// Quote every cell, and neutralise spreadsheet formulas coming from exported data.
const cell = (v) => {
const s = String(v ?? "");
return `"${(/^[=+\-@\t\r]/.test(s) ? `'${s}` : s).replace(/"/g, '""')}"`;
};
const cols = Object.keys(out[0] ?? { system: "" });
process.stdout.write([cols.map(cell).join(","), ...out.map((r) => cols.map((c) => cell(r[c])).join(","))].join("\n") + "\n");
const counts = {};
for (const r of out) for (const f of r.flags.split(" ").filter(Boolean)) counts[f] = (counts[f] ?? 0) + 1;
console.error(`${out.length} accounts reviewed; flagged:`, counts);
A few details are deliberate:
- Matching ignores letter case, because exports rarely agree on it.
- Every cell is quoted, and values beginning with
=,+,-or@are prefixed with an apostrophe. Account names and roles come from systems you don't control, and a spreadsheet would otherwise treat a value such as=HYPERLINK(...)as a formula when a reviewer opens the file. - A missing required column stops the run with a message naming the column, rather than producing a worksheet with every account flagged.
We tested it against 28 cases, including leavers with and without end dates, a future end date, the 89-day and 91-day boundaries, mixed-case email addresses, quoted names containing commas, files saved with Excel's byte-order mark, a formula planted in an account name, and missing columns. We also checked that each safeguard is actually covered: removing any one of them from the script makes the tests fail.
It deliberately doesn't try to judge what access a role should have. That's the reviewer's call, and it's the part of the review an auditor wants to see a person make.
Where access reviews usually go wrong
- An incomplete population. A system left off the list, an export filtered to active users, or contractors kept in a separate directory. Build the system list from your system description, and keep the unfiltered exports.
- The directory account is disabled, but an application account isn't. Disabling someone in your
identity provider doesn't remove a local account in an application that has its own sign-in. This is
the most common reason
LEFT_BUT_ENABLEDturns up. - Decisions that never become changes. "Remove" in the worksheet with no ticket and no completion date doesn't count as removed.
- Reviewers approving their own access, especially administrators.
- Service and shared accounts with no owner. Give each one a named owner and a reason in the exceptions file, and review them like any other account.
- Late reviews. Put the dates in the calendar at the start of the year, with a buffer before quarter end.
How we handle it in our own customer portal
We built the access controls in our customer portal with this review in mind:
- Permission Groups shows how many people hold each role, so the Administrator count is visible at a glance.
- Users lists every person's roles and their last login, which is the review worksheet for the portal itself.
- The Audit Log exports a dated record of role and account changes, the evidence that decided changes were actually made.
When someone leaves, the offboarding request runs the removal in each connected system and produces a report of what succeeded in each one and what needs attention, along with who asked for it and when. It also shows the scheduled license removal and deletion dates. That report is posted to the service ticket and emailed to the requester. It exists because of the second failure above: turning off one account is easy, and proving every account is gone is the part auditors test.
The portal's steps are in Roles & permission groups. For the rest of the audit, see our SOC 2 audit preparation checklist and managing evidence across SOC 2, ISO 27001 and HIPAA. If you'd rather have someone run the program with you, that's what our SOC 2 compliance services do.
Frequently asked questions
How often should SOC 2 access reviews be done?
SOC 2 doesn't specify a frequency. The Trust Services Criteria say "on a periodic basis". Quarterly is the most common choice, with privileged access often reviewed more frequently. Whatever you choose, write it into your control and meet it every time, because a Type 2 audit tests the frequency you stated.
Who should perform the access review?
The system owner or each person's manager, meaning someone with the authority to decide whether the access is needed. Nobody should approve their own access, so have a second person review administrators.
Do disabled accounts need to be in the review?
Keep them in the exported list so the population is complete, but they don't need a decision. Focus the review on enabled accounts, and confirm that accounts removed in earlier reviews are still disabled.
What's the difference between an access review and offboarding?
Offboarding removes access when someone leaves. The access review is the periodic check that catches what offboarding missed, along with access that was never needed. Auditors test both.


