Skip to content
Notifications
Clear all

Check out what I made: A PowerShell script to audit inactive users weekly.

7 Posts
7 Users
0 Reactions
0 Views
(@annas)
Estimable Member
Joined: 2 weeks ago
Posts: 142
Topic starter   [#23026]

Alright, let's cut to the chase. JumpCloud's reporting for user lifecycle management is, frankly, insufficient for any organization that takes security and license cost optimization seriously. The built-in "Inactive Users" report is a snapshot with no historical tracking, and you can't schedule it to run automatically. Waiting for an annual audit to clean up stale accounts is how you end up with a bloated directory and unnecessary attack surface.

I manage several mid-sized deployments (500-1000 users) where even a dozen unused accounts represent a tangible compliance risk and wasted spend. I needed something proactive, automated, and integrated into our existing ticketing and alerting workflow. Since JumpCloud's API is decent, I built a PowerShell script that does what their portal should.

The script runs weekly via a scheduled task on a hardened jumpbox. It performs a full audit, generates actionable reports, and can optionally create tickets in our ITSM system (ServiceNow, in this case). Here's the core logic and why it's built this way.

**Key Script Functions:**

* **Fetches all users** via the JumpCloud API `/systemusers` endpoint, including their `lastLogin` timestamps.
* **Applies a configurable inactivity threshold** (we use 90 days for standard users, 30 for contractors).
* **Differentiates between "Never Logged In"** (provisioned but unused) and "Inactive" (previously active).
* **Tags users with a custom `jc-inactive-audit-date` attribute** via the API to track when they were first flagged. This is critical for avoiding false positives and establishing an audit trail.
* **Generates two CSV reports**: one for immediate action, another for historical tracking.
* **Optionally posts to a Microsoft Teams channel** for SecOps visibility.
* **Can integrate with ITSM** to auto-create tickets for manual review by system owners before termination.

**Why the custom attribute?** Without it, you're blind to trends. If a user is on vacation for 95 days, you don't want to flag them every single week after day 90. The script checks for this attribute. If it's not set, it adds it with the first detection date. If it *is* set and the user is *still* inactive past the threshold, it escalates them in the report. This logic is missing from JumpCloud's native tooling.

Here is the core section that does the heavy lifting. I've sanitized the integration parts for brevity.

```powershell
# Define thresholds
$neverLoggedInThreshold = (Get-Date).AddDays(-30)
$inactiveThreshold = (Get-Date).AddDays(-90)

# Fetch all users from JumpCloud
$jcUsers = Invoke-RestMethod -Uri "https://console.jumpcloud.com/api/systemusers" -Headers $headers -Method Get

$auditReport = @()

foreach ($user in $jcUsers) {
$lastLogin = if ($user.lastLogin) { [datetime]$user.lastLogin } else { $null }

$status = "Active"
$auditFlag = $false

# Check for never logged in
if (-not $lastLogin -and $user.created -lt $neverLoggedInThreshold) {
$status = "NeverLoggedIn"
$auditFlag = $true
}
# Check for inactive
elseif ($lastLogin -and $lastLogin -lt $inactiveThreshold) {
$status = "Inactive"
$auditFlag = $true
}

if ($auditFlag) {
# Check for existing audit tag
$userTags = Invoke-RestMethod -Uri "https://console.jumpcloud.com/api/systemusers/$($user._id)/tags" -Headers $headers -Method Get
$existingAuditTag = $userTags | Where-Object { $_.name -eq 'jc-inactive-audit-date' }

if ($existingAuditTag) {
$firstFlaggedDate = [datetime]$existingAuditTag.value
$escalationNote = "User flagged for review since $firstFlaggedDate"
} else {
# Add new audit tag
$tagBody = @{ name = 'jc-inactive-audit-date'; value = (Get-Date -Format "yyyy-MM-dd") } | ConvertTo-Json
Invoke-RestMethod -Uri "https://console.jumpcloud.com/api/systemusers/$($user._id)/tags" -Headers $headers -Method Post -Body $tagBody -ContentType 'application/json'
$escalationNote = "Newly flagged for review."
}

$auditReport += [PSCustomObject]@{
Username = $user.username
Email = $user.email
Status = $status
LastLogin = $lastLogin
FirstFlaggedDate = if ($existingAuditTag) { $existingAuditTag.value } else { (Get-Date -Format "yyyy-MM-dd") }
EscalationNote = $escalationNote
}
}
}

# Export and handle reports
$auditReport | Export-Csv -Path "Weekly_JumpCloud_Inactive_Users_$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation
```

**Deployment Pitfalls & Requirements:**

* **API Rate Limiting:** You must implement logic to respect JumpCloud's rate limits. My script includes a `Start-Sleep -Seconds 1` between certain API calls.
* **Service Account:** Run this under a service account with `READ` and `TAG_WRITE` permissions on the JumpCloud directory. Never use a global admin's personal API key.
* **Secure Credential Storage:** The API key is stored using PowerShell's `Export-Clixml` with DPAPI, accessible only to the service account on the host it was created on.
* **Idempotency is Key:** The script must be safe to run multiple times. The tag-based logic prevents spamming Teams or creating duplicate tickets.

The output gives us a clear, prioritized list. Users with a `FirstFlaggedDate` from several weeks ago are escalated for immediate review and potential termination. The "NeverLoggedIn" crowd often points to flawed onboarding workflows.

This script has reduced our stale account count by roughly 15% quarter-over-quarter and provides concrete data for compliance auditors. It's a workaround, but until JumpCloud provides equivalent scheduled, historical reporting with programmable actions, it's a necessary one. If you're serious about directory hygiene, you'll need to build something similar.

I'm happy to discuss specific integration challenges or share the full script with the ITSM connector modules.

-- as



   
Quote
(@emilyr)
Estimable Member
Joined: 3 weeks ago
Posts: 123
 

Your point about the snapshot report lacking historical tracking is critical. The inability to track a user's inactivity trend over time means you can't distinguish between someone who's on extended leave and a truly abandoned account. I've had to build similar trend analysis outside of the platform, typically by logging the `lastLogin` timestamps to a time-series database like Prometheus. This allows for setting alerts based on a moving window, say, 30 days of zero activity, rather than a static threshold. Have you considered adding that layer of historical analysis to your script's output?



   
ReplyQuote
(@harryj)
Estimable Member
Joined: 2 weeks ago
Posts: 153
 

Absolutely agree about the trend analysis. The script's first version just flagged anyone past the threshold. That caused false positives for long-term leave. It now keeps a simple CSV log with timestamps and inactivity duration from the previous run. If the duration increases week over week, that's a stronger signal for a truly stale account.

Logging to Prometheus is a great idea for larger setups. I've piped the output to our existing IT monitoring dashboard instead, which gives the team a visual trend without needing another database.

For us, the real trick was tying the trend to our ticketing system's SLA. A new inactive account gets a low-priority ticket, but if the trend shows three weeks of increasing inactivity, it automatically bumps the priority and pings the manager.


Automate the boring stuff.


   
ReplyQuote
(@cloud_infra_vet)
Reputable Member
Joined: 2 months ago
Posts: 187
 

The SLA-driven ticket escalation you've implemented is a clever way to operationalize the trend data. That moves it from a simple alert into an enforceable process, which is where many audits fail.

One caveat with the CSV log approach: watch out for script execution failures or missed runs. If a weekly job fails once, your week-over-week comparison breaks for the next run, potentially causing false negatives. We solved this by having the script check the last successful execution timestamp from the log itself and calculate the trend against that, rather than blindly assuming a seven-day interval.

Integrating with the existing dashboard was the right call. Adding Prometheus adds operational overhead unless you're already deep in that stack for other metrics.



   
ReplyQuote
(@contrarian_kevin)
Reputable Member
Joined: 3 weeks ago
Posts: 190
 

So you're paying a vendor for a core directory service but still need to build and host your own automation to do basic hygiene. That's the hidden cost right there. Your script works until JumpCloud changes their API or deprecates a field.

What's your fallback when that scheduled task fails on the jumpbox? Now you own the monitoring for the monitor.


Just saying.


   
ReplyQuote
(@emma78)
Estimable Member
Joined: 2 weeks ago
Posts: 72
 

Logging to Prometheus is interesting. We use Grafana for dashboards but it's fed by a different system. How do you handle the metric naming and retention for these user login events? I'm wondering if it's worth setting up just for this, or if we should push the data to our existing dashboard like others mentioned.



   
ReplyQuote
(@backend_builder)
Reputable Member
Joined: 4 months ago
Posts: 248
 

That's a great fix for the CSV log problem. We've had the exact same issue when a server reboot killed the scheduled task.

I'd add that you also need to handle the scenario where the log file itself gets rotated or archived by another process. Our script now creates a new entry with a flag if it detects the previous run timestamp is missing or invalid, so at least it doesn't silently skip a week.

You're right about Prometheus overhead. It's powerful, but unless you're already using it for infrastructure metrics, it's a heavy lift for just this one use case.


Latency is the enemy, but consistency is the goal.


   
ReplyQuote