beginner By Mathias Paulenko

User Access Audit Template

A template for reviewing and certifying user access rights across systems, applications, and data repositories.

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Overview

A user access audit verifies that every user has the right level of access to systems, applications, and data. It is a core control for identity governance, least privilege, and compliance with standards like SOC 2, ISO 27001, and PCI-DSS. This template provides a structured way to collect access data, review permissions, certify access, and remediate findings.

When to Use

  • Performing quarterly or annual access reviews.
  • Preparing for a compliance audit or certification.
  • After a role change, reorganization, or merger.
  • When privileged access is suspected to be excessive.
  • After offboarding a user or removing a contractor.

Prerequisites

  • An identity source such as an SSO provider or identity management system.
  • A list of applications, systems, and data repositories under review.
  • Owners or managers for each application who can certify access.
  • A defined access review schedule and escalation process.

Solution

Template

1. Audit Scope

Scope ItemDescription
Period2026-Q2
Systems reviewedAWS, GitHub, Jira, Confluence, Slack, VPN, Google Workspace
PopulationEmployees, contractors, service accounts, privileged admin roles
ReviewersApplication owners, managers, security team
Due date2026-07-15
Exceptions allowedYes, with risk acceptance and expiration

2. Identity Inventory

User IDNameTypeDepartmentStatusLast Reviewed
alice@example.comAlice ChenEmployeeEngineeringActive2026-03-31
bob@example.comBob SmithContractorFinanceActive2026-03-31
svc-api-prodAPI ServiceService accountPlatformActive2026-05-15
carol@example.comCarol JonesEmployeeMarketingInactive2026-01-31

3. Access Mapping

UserSystemRole / PermissionBusiness JustificationReviewerDecision
alice@example.comAWSPowerUserManages infrastructurePlatform leadKeep
bob@example.comGitHubReadReviews pull requestsEngineering managerKeep
alice@example.comJiraAdminConfigures workflowsIT leadRevoke
svc-api-prodAWSS3 read-onlyApplication reads reportsPlatform leadKeep
carol@example.comSlackMemberLeft companyHRRevoke

4. Privileged Access Review

UserSystemPrivileged RoleJustificationRiskReviewerDecision
alice@example.comAWSRoot accessEmergency break-glassHighCISOKeep with MFA
dave@example.comGitHubOrganization ownerManages repositoriesHighCTOKeep
eve@example.comVPNFull tunnelRemote admin accessHighSecurity leadRevoke

5. Certification Log

ApplicationReviewerStatusDateNotes
AWSPlatform leadCertified2026-07-102 revocations pending
GitHubCTOCertified2026-07-081 orphan account removed
JiraIT leadIn progress2026-07-05Admin role under review
SlackHRCertified2026-07-093 inactive accounts revoked

6. Remediation Plan

FindingActionOwnerDue DateStatus
Excessive admin rights in JiraDowngrade to userIT lead2026-07-20Open
Inactive Slack accountDeactivateHR2026-07-12Done
Orphaned service accountInvestigate and disablePlatform team2026-07-18Open
Missing MFA on privileged usersEnforce MFAIAM team2026-07-15In progress

Explanation

The template connects identities to permissions, business justification, and accountable reviewers. Without this structure, organizations accumulate stale accounts and over-privileged users, increasing both insider risk and external attack surface. Regular access reviews are required by most security frameworks and are a practical way to enforce least privilege.

Variants

  • Application-specific access review: Focuses on one system, such as AWS IAM or GitHub organization access.
  • Privileged access review: Only reviews admin, root, or emergency access accounts.
  • Service account audit: Reviews non-human identities and their API keys or credentials.
  • Contractor access review: Time-bound review for external users with temporary access.
  • Data access audit: Focuses on users who can access sensitive databases, data lakes, or analytics tools.

What Works

  • Automate identity collection from the SSO or identity provider.
  • Send reminders to reviewers before the due date.
  • Require business justification for every privileged role.
  • Revoke access immediately when a user changes role or leaves.
  • Schedule quarterly reviews for privileged access and annual reviews for general access.
  • Document risk acceptance for necessary exceptions.
  • Track remediation until every finding is closed.

Common Mistakes

  • Reviewing access only once a year without follow-up.
  • Letting managers keep access for employees who changed roles.
  • Ignoring service accounts and shared credentials.
  • Skipping privileged access or emergency break-glass accounts.
  • Not linking access decisions to business justification.
  • Failing to verify that revocations actually happened.
  • Storing review evidence in scattered emails or documents.

Advanced Solutions

Automated access review with Okta API

Pull user access data from Okta and generate a review report automatically:

import requests
import csv
from datetime import datetime, timedelta
from dataclasses import dataclass
from typing import List

@dataclass
class UserAccessRecord:
    user_id: str
    user_name: str
    status: str
    last_login: str
    assigned_apps: List[str]
    admin_roles: List[str]

class OktaAccessReviewer:
    def __init__(self, api_token: str, domain: str):
        self.headers = {
            "Authorization": f"SSWS {api_token}",
            "Accept": "application/json",
        }
        self.base_url = f"https://{domain}/api/v1"

    def get_inactive_users(self, days: int = 90) -> List[dict]:
        """Find users who haven't logged in within the specified period."""
        cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat() + "Z"
        users = []
        params = {"filter": f'status eq "ACTIVE"'}
        resp = requests.get(
            f"{self.base_url}/users",
            headers=self.headers,
            params=params,
        )
        for user in resp.json():
            last_login = user.get("lastLogin")
            if last_login and last_login < cutoff:
                users.append({
                    "id": user["id"],
                    "email": user["profile"]["email"],
                    "last_login": last_login,
                    "status": user["status"],
                })
        return users

    def get_user_apps(self, user_id: str) -> List[str]:
        """Get applications assigned to a user."""
        resp = requests.get(
            f"{self.base_url}/users/{user_id}/appLinks",
            headers=self.headers,
        )
        return [app["label"] for app in resp.json()]

    def get_admin_roles(self, user_id: str) -> List[str]:
        """Get admin roles assigned to a user."""
        resp = requests.get(
            f"{self.base_url}/users/{user_id}/roles",
            headers=self.headers,
        )
        return [role["type"] for role in resp.json()]

    def generate_review_report(self, output_file: str) -> None:
        """Generate a CSV report of all active users and their access."""
        resp = requests.get(
            f"{self.base_url}/users",
            headers=self.headers,
            params={"filter": 'status eq "ACTIVE"'},
        )
        with open(output_file, "w", newline="") as f:
            writer = csv.writer(f)
            writer.writerow([
                "User ID", "Email", "Status", "Last Login",
                "Assigned Apps", "Admin Roles"
            ])
            for user in resp.json():
                apps = self.get_user_apps(user["id"])
                roles = self.get_admin_roles(user["id"])
                writer.writerow([
                    user["id"],
                    user["profile"]["email"],
                    user["status"],
                    user.get("lastLogin", "Never"),
                    "; ".join(apps),
                    "; ".join(roles),
                ])

# Example usage
reviewer = OktaAccessReviewer(api_token="YOUR_TOKEN", domain="yourorg.okta.com")
inactive = reviewer.get_inactive_users(days=90)
for u in inactive:
    print(f"INACTIVE: {u['email']} - last login: {u['last_login']}")
reviewer.generate_review_report("access_review_q2.csv")

AWS IAM access analyzer for automated finding detection

Use AWS IAM Access Analyzer to detect unused permissions and cross-account access:

#!/bin/bash
set -euo pipefail

# Create an analyzer if it doesn't exist
ANALYZER_NAME="org-access-analyzer"
aws accessanalyzer create-analyzer \
  --analyzer-name "$ANALYZER_NAME" \
  --type ORGANIZATION \
  --region us-east-1

# List all findings
echo "=== Active Findings ==="
aws accessanalyzer list-findings \
  --analyzer-arn "$(aws accessanalyzer list-analyzers --query 'analyzers[0].arn' --output text)" \
  --filter '{"status":{"eq":["ACTIVE"]}}' \
  --query 'findings[*].{id:id,resource:resource.resourceArn,type:findingType,createdAt:createdAt}' \
  --output table

# Export findings to CSV for audit trail
aws accessanalyzer list-findings \
  --analyzer-arn "$(aws accessanalyzer list-analyzers --query 'analyzers[0].arn' --output text)" \
  --query 'findings[*].{id:id,resource:resource.resourceArn,type:findingType,createdAt:createdAt}' \
  --output json > iam-findings-$(date +%Y%m%d).json

GitHub organization access audit script

Audit GitHub organization members and their roles programmatically:

const { Octokit } = require("@octokit/rest");

async function auditGitHubOrg(orgName, token) {
  const octokit = new Octokit({ auth: token });
  const findings = [];

  // Get all organization members
  const members = await octokit.paginate(
    octokit.rest.orgs.listMembers,
    { org: orgName, per_page: 100 }
  );

  for (const member of members) {
    // Check if 2FA is enabled
    const { data: mfaStatus } = await octokit.rest.orgs.getMembershipForUser({
      org: orgName,
      username: member.login,
    });

    // Get user's public keys to verify SSH key rotation
    let keyCount = 0;
    try {
      const { data: keys } = await octokit.rest.users.listPublicKeysForUser({
        username: member.login,
      });
      keyCount = keys.length;
    } catch (e) {
      // API may rate limit
    }

    findings.push({
      login: member.login,
      role: mfaStatus.role,
      two_factor: member.two_factor_authentication ? "enabled" : "disabled",
      public_keys: keyCount,
    });
  }

  // Flag users without 2FA
  const noMfa = findings.filter(f => f.two_factor === "disabled");
  if (noMfa.length > 0) {
    console.log(`\nUSERS WITHOUT 2FA (${noMfa.length}):`);
    noMfa.forEach(u => console.log(`  - ${u.login} (role: ${u.role})`));
  }

  // Flag admins
  const admins = findings.filter(f => f.role === "admin");
  console.log(`\nORGANIZATION ADMINS (${admins.length}):`);
  admins.forEach(u => console.log(`  - ${u.login} (2FA: ${u.two_factor})`));

  return findings;
}

auditGitHubOrg("your-org", process.env.GITHUB_TOKEN)
  .then(() => console.log("\nAudit complete."))
  .catch(err => console.error("Audit failed:", err.message));

Shared Account Remediation Checklist

  • Inventory all shared accounts (root, admin, service)
  • Identify which individuals use each shared account
  • Create individual accounts for each user
  • Disable shared account after migration
  • Implement break-glass procedure for emergency access
  • Log all break-glass usage with automatic alerts

Frequently Asked Questions

Who should certify access?
The system owner or the user's direct manager is the best reviewer. For sensitive systems, the security team or data owner may also approve.
What is an orphan account?
An orphan account is an active account no longer associated with a known user or owner, often after offboarding or team changes. These should be disabled or reclaimed.
How do we make access reviews less tedious?
Use identity governance tools that pull access data automatically, provide reviewer-friendly dashboards, and auto-revoke low-risk inactive accounts after approval.