> For the complete documentation index, see [llms.txt](https://developer.harness.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://developer.harness.io/harness-platform/use-harness-platform/platform-access-control/get-active-inactive-users.md).

# Get active and inactive users

Identify which users logged in to your Harness account over a specific time period. This topic provides a Python script that queries the [Harness Audit API](https://apidocs.harness.io/audit) for `LOGIN` events across a date range, compares the results against every user in your account, and categorizes each user as active, inactive, or deleted.

Login activity supports several account management tasks:

* **Compliance and auditing**: Track user access for security and regulatory requirements.
* **License management**: Identify active users to optimize license usage.
* **User lifecycle management**: Find inactive users who may need to be offboarded.

***

### What you will learn in this topic <a href="#what-you-will-learn-in-this-topic" id="what-you-will-learn-in-this-topic"></a>

By the end of this topic, you will be able to:

* [Understand how the script works](#how-the-script-works) and which output files it produces.
* [Run the script](#run-the-script) with a custom date range or environment variables.
* [Review the script parameters](#script-parameters) to control the account and reporting window.
* [Interpret the output](#interpret-the-output) files and count or extract user records.
* [Troubleshoot](#troubleshooting) authentication, permission, and rate limit errors.

***

### Before you begin <a href="#before-you-begin" id="before-you-begin"></a>

Before you run the script, ensure you have the following:

* **Python 3.x**: Installed on the system where you run the script.
* **Python requests library**: Install it with `pip install requests`.
* **API token**: A token with permission to read audit logs and users. For more information, see [Manage API keys](/harness-platform/use-harness-platform/automation/api/add-and-manage-api-keys.md).
* **Audit log permission**: Permission to view audit logs in your Harness account. For more information, see [Permissions reference](/harness-platform/use-harness-platform/platform-access-control/permissions-reference.md).
* **Harness account ID**: Available in any Harness URL, for example `https://app.harness.io/ng/account/<ACCOUNT_ID>/...`.

***

### How the script works <a href="#how-the-script-works" id="how-the-script-works"></a>

Understand the output categories before you act on the results, because an empty login record does not always mean the account is safe to delete. The script queries the Harness Audit API for `LOGIN` events within a date range, compares that data against all users in your account, and writes three files.

* **active\_users.ndjson**: Users who logged in during the specified time period.
* **inactive\_users.ndjson**: Users who exist in the account but did not log in during the specified time period.
* **deleted\_users.ndjson**: Users who logged in during the specified time period but no longer exist in the account.

The output files use [NDJSON format](http://ndjson.org/) (newline-delimited JSON), where each line is a valid JSON object representing one user record.

***

### Run the script <a href="#run-the-script" id="run-the-script"></a>

Save the [complete script](#complete-script) as `get_inactive_users.py`, then run it from the command line with your environment URL and credentials. By default, the script analyzes the last 30 days of login activity.

```bash
# Using API key (recommended) <a href="#using-api-key-recommended" id="using-api-key-recommended"></a>
python3 get_inactive_users.py \
  --env app.harness.io/ng/account/<YOUR_ACCOUNT_ID>/ \
  --apikey YOUR_API_KEY

# Using Bearer token <a href="#using-bearer-token" id="using-bearer-token"></a>
python3 get_inactive_users.py \
  --env app.harness.io/ng/account/<YOUR_ACCOUNT_ID>/ \
  --bearer YOUR_BEARER_TOKEN
```

#### Specify a custom date range <a href="#specify-a-custom-date-range" id="specify-a-custom-date-range"></a>

Set an explicit window when you report on a fixed audit period, such as a quarter, rather than the trailing 30 days. Pass the `--start` and `--end` parameters:

```bash
python3 get_inactive_users.py \
  --env app.harness.io/ng/account/<YOUR_ACCOUNT_ID>/ \
  --apikey YOUR_API_KEY \
  --start "2025-01-01 00:00" \
  --end "2025-01-31 23:59"
```

#### Use environment variables <a href="#use-environment-variables" id="use-environment-variables"></a>

Set credentials as environment variables to keep tokens out of your shell history and process list. The script reads `HARNESS_API_KEY` for an API key and `HARNESS_BEARER` for a Bearer token.

```bash
# Set environment variable <a href="#set-environment-variable" id="set-environment-variable"></a>
export HARNESS_API_KEY="your_api_key_here"

# Run script without --apikey parameter <a href="#run-script-without-apikey-parameter" id="run-script-without-apikey-parameter"></a>
python3 get_inactive_users.py \
  --env app.harness.io/ng/account/<YOUR_ACCOUNT_ID>/ \
  --start "2025-01-01 00:00"
```

***

### Script parameters <a href="#script-parameters" id="script-parameters"></a>

Use these parameters to control the target account, the authentication method, and the reporting window.

| Parameter  | Required | Description                                                                          | Default                   | Example                             |
| ---------- | -------- | ------------------------------------------------------------------------------------ | ------------------------- | ----------------------------------- |
| `--env`    | Yes      | Harness environment URL in the format `<domain>.harness.io/ng/account/<account_id>/` | None                      | `app.harness.io/ng/account/abc123/` |
| `--apikey` | No\*     | Harness API key for authentication                                                   | `HARNESS_API_KEY` env var | `pat.abc123.xyz...`                 |
| `--bearer` | No\*     | Bearer token for authentication                                                      | `HARNESS_BEARER` env var  | `eyJhbGc...`                        |
| `--start`  | No       | Start date and time in `YYYY-MM-DD HH:MM` format                                     | 30 days ago               | `2025-01-01 00:00`                  |
| `--end`    | No       | End date and time in `YYYY-MM-DD HH:MM` format                                       | Current time              | `2025-01-31 23:59`                  |

\* One of `--apikey` or `--bearer` is required, or the corresponding environment variable.

***

### Interpret the output <a href="#interpret-the-output" id="interpret-the-output"></a>

Read the output files to decide which accounts to offboard and which to retain. The script writes all three NDJSON files to the current directory.

#### active\_users.ndjson <a href="#activeusersndjson" id="activeusersndjson"></a>

Contains audit log entries for users who logged in during the specified time period. Each line includes:

```json
{
  "authenticationInfo": {
    "labels": {
      "userId": "user123",
      "email": "user@example.com"
    }
  },
  "timestamp": 1706745600000,
  "action": "LOGIN"
}
```

#### inactive\_users.ndjson <a href="#inactiveusersndjson" id="inactiveusersndjson"></a>

Contains user records for users who exist in the account but did not log in during the specified time period. Each line includes:

```json
{
  "uuid": "user456",
  "email": "inactive@example.com",
  "name": "Inactive User",
  "disabled": false,
  "locked": false
}
```

#### deleted\_users.ndjson <a href="#deletedusersndjson" id="deletedusersndjson"></a>

Contains audit log entries for users who logged in during the specified time period but no longer exist in the account.

#### Analyze the output <a href="#analyze-the-output" id="analyze-the-output"></a>

Process the NDJSON files with command-line tools when you need a quick count, or with Python when you need to feed the results into another system.

To count the records in each category, use `wc`:

```bash
# Count active users <a href="#count-active-users" id="count-active-users"></a>
wc -l active_users.ndjson

# Count inactive users <a href="#count-inactive-users" id="count-inactive-users"></a>
wc -l inactive_users.ndjson

# Count deleted users <a href="#count-deleted-users" id="count-deleted-users"></a>
wc -l deleted_users.ndjson
```

To extract email addresses, use `jq`:

```bash
# List active user emails <a href="#list-active-user-emails" id="list-active-user-emails"></a>
jq -r '.authenticationInfo.labels.email' active_users.ndjson

# List inactive user emails <a href="#list-inactive-user-emails" id="list-inactive-user-emails"></a>
jq -r '.email' inactive_users.ndjson
```

To process the records programmatically, read them in Python:

```python
import json

# Read and process active users <a href="#read-and-process-active-users" id="read-and-process-active-users"></a>
with open('active_users.ndjson', 'r') as f:
    active_users = [json.loads(line) for line in f]
    active_emails = [user['authenticationInfo']['labels']['email'] for user in active_users]
    print(f"Active users: {len(active_emails)}")
    print(active_emails)
```

For accounts with many users or extensive audit history, the script can take several minutes to complete. It paginates through the data, fetching up to 1000 audit log entries or 100 users per page, and prints progress as it runs.

***

### Complete script <a href="#complete-script" id="complete-script"></a>

Save the following as `get_inactive_users.py`.

<details>

<summary>get_inactive_users.py</summary>

```python
import argparse
import os
import getpass
import json
from datetime import datetime, timedelta
import requests
import time
import re

def validate_date(date_str):
    """Validate date format (YYYY-MM-DD HH:MM) and return parsed datetime."""
    try:
        return datetime.strptime(date_str.strip(), "%Y-%m-%d %H:%M")
    except ValueError:
        raise argparse.ArgumentTypeError(
            f"Invalid date format: '{date_str}'. Use YYYY-MM-DD HH:MM (e.g., 2025-08-25 14:30)."
        )

def validate_env_url(env_url):
    """Validate Harness environment URL format (e.g., qa.harness.io/ng/account/px7xd_BFRCi-pfWPYXVjvw/)."""
    pattern = r"^(https?://)?([a-zA-Z0-9-]+\.harness\.io)/ng/account/([a-zA-Z0-9_-]+)/?$"
    match = re.match(pattern, env_url.strip())
    if not match:
        raise argparse.ArgumentTypeError(
            f"Invalid environment URL: '{env_url}'. Expected format: <domain>.harness.io/ng/account/<account_id>/ (e.g., qa.harness.io/ng/account/px7xd_BFRCi-pfWPYXVjvw/)."
        )
    return match.group(2), match.group(3)  # Return domain and account_id

def to_epoch_ms(date_str: str) -> int:
    """Convert YYYY-MM-DD HH:MM string to epoch milliseconds."""
    dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M")
    return int(dt.timestamp() * 1000)

def stream_audits(account_id, headers, start_ms, end_ms, out, base_domain):
    """Stream audit logs page by page and save unique active users (NDJSON format)."""
    base_url = f"https://{base_domain}/gateway/audit/api/audits/list"
    params = {"routingId": account_id, "accountIdentifier": account_id, "pageSize": 1000}
    payload = {
        "scopes": [{"accountIdentifier": account_id}],
        "filterType": "Audit",
        "actions": ["LOGIN"],
        "startTime": start_ms,
        "endTime": end_ms,
    }

    pageIndex = 0
    userId = {}

    with open(out, "w", encoding="utf-8") as f:
        while True:
            params["pageIndex"] = pageIndex
            pageIndex += 1
            with requests.post(base_url, params=params, headers=headers, json=payload, verify=True) as resp:
                resp.raise_for_status()
                data = resp.json()["data"]
                totalPages = data["totalPages"]
                print(f"Processing page {pageIndex}/{totalPages}")

                for item in data["content"]:
                    uid = item["authenticationInfo"]["labels"]["userId"]
                    if userId.get(uid) is None:
                        userId[uid] = True
                        f.write(json.dumps(item, ensure_ascii=False) + "\n")

                if pageIndex >= totalPages:
                    break

    return userId

def get_all_inactive_users(account_id, headers, unique_users, out, base_domain):
    """Get all users and mark active ones, writing inactive users in NDJSON format."""
    base_url = f"https://{base_domain}/gateway/ng/api/user/batch"
    params = {"accountIdentifier": account_id, "pageIndex": 0, "pageSize": 100}
    headers_with_content_type = headers.copy()
    headers_with_content_type["content-type"] = "application/json"
    payload = {}

    with open(out, "w", encoding="utf-8") as f:
        page_index = 0
        while True:
            params["pageIndex"] = page_index
            page_index += 1
            with requests.post(base_url, params=params, headers=headers_with_content_type, json=payload, verify=True) as resp:
                resp.raise_for_status()
                response = resp.json()
                data = response["data"]
                totalPages = data["totalPages"]
                print(f"Processing page {page_index}/{totalPages}")

                for item in data["content"]:
                    uid = item["uuid"]
                    if uid in unique_users:
                        unique_users[uid] = False  # mark user as existing
                    else:
                        f.write(json.dumps(item, ensure_ascii=False) + "\n")

                if page_index >= totalPages:
                    break

def finalize_deleted_users(unique_users, active_file, deleted_file):
    """Stream active_users.ndjson and move deleted ones into deleted_users.ndjson."""
    tmp_file = active_file + ".tmp"

    with open(active_file, "r", encoding="utf-8") as f_in, \
         open(tmp_file, "w", encoding="utf-8") as f_out, \
         open(deleted_file, "w", encoding="utf-8") as f_del:

        for line in f_in:
            item = json.loads(line)
            uid = item["authenticationInfo"]["labels"]["userId"]

            if unique_users.get(uid, False):  # still True = deleted
                f_del.write(json.dumps(item, ensure_ascii=False) + "\n")
            else:
                f_out.write(json.dumps(item, ensure_ascii=False) + "\n")

    os.replace(tmp_file, active_file)
    print(f"✅ Finalized active/deleted users. Active={sum(1 for _ in open(active_file))}, Deleted={sum(1 for _ in open(deleted_file))}")

def parse_arguments():
    """Parse and validate command-line arguments."""
    parser = argparse.ArgumentParser(
        description="Access audit logs and user list to get active, inactive and deleted users for the account.",
        epilog="Example: python3 get_inactive_users.py --env qa.harness.io/ng/account/px7xd_BFRCi-pfWPYXVjvw/ --start '2025-08-01 00:00' --apikey abc123"
    )
    parser.add_argument(
        "--env",
        help="Harness environment URL (e.g., qa.harness.io/ng/account/px7xd_BFRCi-pfWPYXVjvw/). Required. The account ID is extracted from this URL.",
        required=True,
        type=validate_env_url
    )
    parser.add_argument(
        "--apikey",
        help="Harness API key (use x-api-key header). Provide either this or --bearer (If both are provided, --apikey will be used). Can also be set via HARNESS_API_KEY environment variable."
    )
    parser.add_argument(
        "--bearer",
        help="Bearer token (use Authorization header). Provide either this or --apikey (If both are provided, --apikey will be used). Can also be set via HARNESS_BEARER environment variable."
    )
    parser.add_argument(
        "--start",
        help="Start date and time for audit logs in YYYY-MM-DD HH:MM format (e.g., 2025-08-01 00:00). Defaults to 30 days prior to current time.",
        type=validate_date,
        default=(datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d %H:%M")
    )
    parser.add_argument(
        "--end",
        help="End date and time for audit logs in YYYY-MM-DD HH:MM format (e.g., 2025-08-25 23:59). Defaults to current time.",
        type=validate_date,
        default=datetime.now().strftime("%Y-%m-%d %H:%M")
    )

    args = parser.parse_args()

    # Extract domain and account_id from env URL
    base_domain, account_id = args.env

    # Validate that only one of API key or Bearer token is provided
    api_key = args.apikey or os.getenv("HARNESS_API_KEY")
    bearer = args.bearer or os.getenv("HARNESS_BEARER")

    if not api_key and not bearer:
        print("No authentication provided. Please choose one of the following:")
        choice = input("Use API key or Bearer token? [api/bearer]: ").strip().lower()
        if choice == "api":
            api_key = getpass.getpass("Enter API key: ").strip()
            if not api_key:
                parser.error("API key cannot be empty.")
        elif choice == "bearer":
            bearer = getpass.getpass("Enter Bearer token: ").strip()
            if not bearer:
                parser.error("Bearer token cannot be empty.")
        else:
            parser.error("Invalid choice. Please select 'api' or 'bearer'.")

    # Set headers based on authentication method
    headers = {}
    if api_key:
        headers["x-api-key"] = api_key.strip()
    elif bearer:
        headers["Authorization"] = "Bearer " + bearer.strip()

    # Convert dates to epoch milliseconds
    start_ms = to_epoch_ms(args.start.strftime("%Y-%m-%d %H:%M"))
    end_ms = to_epoch_ms(args.end.strftime("%Y-%m-%d %H:%M"))

    if start_ms > end_ms:
        parser.error(f"Start time ({args.start.strftime('%Y-%m-%d %H:%M')}) cannot be after end time ({args.end.strftime('%Y-%m-%d %H:%M')})")

    return {
        "account_id": account_id,
        "headers": headers,
        "start_ms": start_ms,
        "end_ms": end_ms,
        "base_domain": base_domain,
        "out_active_users": "active_users.ndjson",
        "out_inactive_users": "inactive_users.ndjson",
        "out_deleted_users": "deleted_users.ndjson"
    }

def main():
    try:
        config = parse_arguments()
        account_id = config["account_id"]
        headers = config["headers"]
        start_ms = config["start_ms"]
        end_ms = config["end_ms"]
        base_domain = config["base_domain"]
        out_active_users = config["out_active_users"]
        out_inactive_users = config["out_inactive_users"]
        out_deleted_users = config["out_deleted_users"]

        start_date = datetime.fromtimestamp(start_ms / 1000).strftime("%Y-%m-%d %H:%M")
        end_date = datetime.fromtimestamp(end_ms / 1000).strftime("%Y-%m-%d %H:%M")

        print(f"Fetching audit logs for account={account_id}, between {start_date} and {end_date}...")
        unique_users = stream_audits(account_id, headers, start_ms, end_ms, out_active_users, base_domain)
        print(f"✅ Saved active users to {out_active_users}")

        print(f"Fetching all users for account={account_id}...")
        get_all_inactive_users(account_id, headers, unique_users, out_inactive_users, base_domain)
        print(f"✅ Saved inactive users to {out_inactive_users}")

        print("Finalizing deleted users...")
        finalize_deleted_users(unique_users, out_active_users, out_deleted_users)
        print(f"✅ Saved deleted users to {out_deleted_users}")
    except Exception as e:
        print(f"Error: {str(e)}")
        exit(1)

if __name__ == "__main__":
    main()
```

</details>

***

### Troubleshooting <a href="#troubleshooting" id="troubleshooting"></a>

Match the error the script prints to the corresponding fix.

<details>

<summary>401 Unauthorized</summary>

**Solution:** Verify that your API key or Bearer token is valid and has the necessary permissions to access audit logs. For more information, see [Manage API keys](/harness-platform/use-harness-platform/automation/api/add-and-manage-api-keys.md).

</details>

<details>

<summary>403 Forbidden</summary>

**Solution:** Your API key or Bearer token does not have permission to view audit logs or user information. Confirm you have the necessary [permissions](/harness-platform/use-harness-platform/platform-access-control/permissions-reference.md) to access these resources.

</details>

<details>

<summary>Invalid date format</summary>

**Solution:** Ensure dates use the format `YYYY-MM-DD HH:MM`, for example `2025-01-01 00:00`.

</details>

<details>

<summary>429 Too Many Requests</summary>

**Solution:** The script exceeded the Harness API rate limits. Wait a few minutes and run it again. For more information, see [Rate limits](/harness-platform/use-harness-platform/rate-limits.md).

</details>

***

### Related articles <a href="#related-articles" id="related-articles"></a>

* [Manage users](/harness-platform/use-harness-platform/platform-access-control/add-users.md): Add, edit, and delete users, and act on the inactive accounts this script identifies.
* [Audit trail](/harness-platform/use-harness-platform/governance/audit-trail.md): Review the audit events that this script queries.
* [Manage API keys](/harness-platform/use-harness-platform/automation/api/add-and-manage-api-keys.md): Create the token the script uses to authenticate.
* [Harness API quickstart](/harness-platform/use-harness-platform/automation/api/api-quickstart.md): Understand how to authenticate and call Harness APIs.
