Identifying Inactive Users in a Remote Hybrid World

Identifying inactive user accounts is an important task for IT organizations. Inactive user accounts can:

  • Consume resources such as licenses, laptops, mailboxes, and home drives
  • Represent potential vectors for compromise

License management has become much more important as organizations move to the cloud, since cloud license allocation tends to be more dynamic and immediate (i.e., the user must have a license to access the system), and licenses are often charged monthly. Removing inactive users from licensed applications in a timely manner can help keep the total license pool lean and minimize costs.

Inactive accounts that aren’t disabled represent potential vectors for compromise. They may be associated with former employees who don’t have the organization’s best interests in mind, or they may represent users who were never active and still have a default (and possibly known) password. Cleanup of these accounts is an important activity in keeping an organization secure, regardless of the additional benefits it brings in cost savings.

Identifying inactive accounts is particularly important for organizations without identity lifecycle management. But even for organizations that have implemented such systems, simple activity scripts can help find accounts that have slipped through the cracks or are out of scope (e.g., if contractors aren’t managed by your identity system).

In the past you could focus on Active Directory (AD) to identify inactive users. Activities such as logging on to a workstation or accessing on-premises Exchange would involve authentication to AD, which would generally be sufficient to differentiate between active and inactive users.

As organizations implement a hybrid model and move previously on-premises workloads to the cloud (e.g., Exchange, SharePoint), users may find themselves authenticating to AD less frequently for application purposes. Remote users may be authenticating to their workstations using cached credentials (which don’t generate AD logon events) or using non-AD domain-joined machines for work. The user in Figure 1, for example, is active in the cloud but isn’t accessing on-premises systems or generating authentication events in AD.

Figure 1 – Active or inactive user?

This situation may result in active users being mistakenly classified as inactive if their workloads don’t require them to frequently authenticate on-premises. We need to look beyond on-premises and include authentication activity in the cloud as well.

In this blog we’ll look at some relatively simple ways you can pull data from both AD and Azure AD using PowerShell to get a better picture of your users’ activity in the Microsoft identity space.

Inactive Users in Active Directory

Methods for checking for inactive user accounts in AD have been around for a while. Here’s a quick recap.

An AD domain controller updates an attribute called lastLogon on the user object almost every time it authenticates that user. However, this attribute isn’t replicated and therefore has different values on each domain controller depending on when (and if) a given user has been authenticated by a given domain controller. To get an accurate last authentication date for a user would require querying every domain controller in the domain to retrieve the lastLogon attribute and determining the latest value. Although this is certainly possible, it’s not a simple undertaking.

Domain controllers log events to the Windows Security Log when authenticating users, and you can establish a user’s last authentication date by retrieving these events. This generally isn’t practical unless your organization has implemented a Security Information and Event Management (SIEM) system, since the security logs on domain controllers produce a lot of data and they roll over frequently.

In the absence of a SIEM system, the easiest method to find inactive accounts in AD is to query the lastLogonTimestamp attribute. This attribute is replicated to all domain controllers (unlike lastLogon), making it much easier to query. However, AD places some limits on how frequently it’s updated to prevent authentication events from generating large amounts of replication traffic.

When a domain controller processes an authentication, in most cases it will update lastLogon (which doesn’t replicate). It may also update lastLogonTimestamp (which does replicate) if the lastLogonTimestamp value exceeds a randomly chosen threshold between 9 and 14 days older than the new lastLogon value. This means the replicated value of lastLogonTimestamp could be up to 14 days older than the latest lastLogon value. This is illustrated in Figure 2, where an authentication event has updated the lastLogon value on DC-01, but didn’t trigger the lastLogonTimestamp update, so it’s 12 days out of date.

Figure 2 – lastLogon vs. lastLogonTimestamp

If you query for users whose lastLogonTimestamp value is longer than 90 days ago, you can only guarantee that the returned users haven’t logged on within the past 76 days. It follows then that querying against lastLogonTimestamp should only be done for intervals where the level of accuracy it supports is acceptable.

It’s possible to improve the resolution of lastLogonTimestamp by tweaking the msDS-LogonTimeSyncInterval attribute on the domain object, which will impact AD replication traffic. If you do need greater precision, in most cases a better approach is to instead query lastLogon on each domain controller and consolidate the results rather than deal with low-level AD settings.

Both lastLogon and lastLogonTimestamp are stored in AD as “file time” formatted UTC timestamps. File time is a Windows date time format that records time as the number of nanoseconds since the year 1601. In the VBScript days this was a bit of pain to query for, since calculating it was non-trivial. But since PowerShell lets you access .NET classes, generating an arbitrary file time value is now simple:

[DateTime]::Now.AddDays(-90).ToFiletimeUTC() 132610236039634766
Code language: CSS (css)

This code generates the FileTime format for a day 90 days in the past. With that information, it’s easy to construct an LDAP query filter that finds users whose lastLogon or lastLogonTimestamp value is before or after that time.

You can use the Get-ADUser cmdlet from the AD PowerShell module:

Get-ADUser -LDAPFilter "(lastLogonTimestamp<=132610236039634766)"
Code language: JavaScript (javascript)

to do this in a single command:

Get-ADUser -LDAPFilter "(lastLogonTimestamp<=$([DateTime]::Now.AddDays(- 90).ToFiletimeUTC()))"
Code language: PHP (php)

Inactive Users in Azure Active Directory

Azure AD is a little trickier to evaluate activity in. In the past, the principal mechanism available was to pull the sign-in data from the Azure logs, which was painful if you weren’t ingesting those logs into a SIEM system. Microsoft has made a lastSignInDateTime attribute available in that portal, as Figure 3 shows, that tracks the last interactive sign-in for users. However, this isn’t available through either the Get-AzureADUser or Get-MSOLUser cmdlet, which administrators are typically most familiar with. Instead, lastSignInDateTime must be retrieved through the Microsoft Graph Rest API.

Figure 3 – lastSignInDateTime

Obtaining lastSignInDateTime

The published documentation focuses on obtaining the lastSignInDateTime attribute by querying the Microsoft Graph API directly. However, almost everything in the Graph API can instead be retrieved using the Microsoft.Graph modules, which are semi-automatically generated wrappers around the Graph APIs.

To get lastSignInDateTime with the Microsoft.Graph cmdlets, you need to:

  1. Use the beta Graph endpoint
  2. Specifically request the signInActivity property when requesting the users using Get-MgUser

To use the beta Graph endpoint, use the Select-MgProfile cmdlet; for example:

Select-MgProfile -Name beta

This will tell the Microsoft.Graph module to use the beta endpoint in all subsequent requests.

The lastSignInDateTime attribute is a child of a property called signInActivity. This property isn’t returned by default when using Get-MgUser. Instead, it must be explicitly requested along with any other attributes using the Property parameter of the cmdlet; for example:

$users = Get-MgUser -All -Property signInActivity,userPrincipalName
Code language: PHP (php)

Because signInActivity is a composite property, we need to then expand it using the Select-Object cmdlet:

$users | Select-Object userprincipalname -ExpandProperty signinactivity
Code language: JavaScript (javascript)

Unfortunately, we can’t filter on lastSignInDateTime in the Get-MgUser cmdlet, which means that we need to retrieve all users (which can take a while in a large environment) and then post-process as follows:

$users | Select-Object userprincipalname -ExpandProperty signinactivity | where-object {($_.lastSignInDateTime -ne $null) -and ([DateTime]$_.lastSignInDateTime -le [DateTime]::Now.AddDays(-90))} | select-object userprincipalname,lastsignindatetime
Code language: PHP (php)

There are two points to note here when we do the date comparison:

  1. Users who have never signed in, or whose last sign-in predates the implementation of this feature, will have no lastSignInDateTime value. Therefore, you need to check whether the value exists before you try to compare it to your target date.
  2. The Get-MgUser cmdlet returns the lastSignInDateTime value as a string in a non-sortable format, so it needs to be converted to do the comparison.

It should be noted that a user’s sign-in frequency is highly dependent on what Azure protected applications they are accessing and how they are accessing them. For example, a user who only accesses Exchange Online and OneDrive for Business from thick clients may not generate many sign-in events because of the way those applications cache authentication, whereas a user logging time to a SaaS timecard application protected by Azure may be registering weekly or daily sign-ins. Some of this can be tweaked using policies in Azure but will generally be a tradeoff on user convenience versus authentication events.

Users who are never signed in won’t be returned by the above approach. To determine whether such a user is inactive, you’d need to compare the user creation date with the current date.

For larger tenants, it’s better to directly query the API (e.g., using Invoke-MgGraphRequest or Invoke-RestMethod). This method allows the date comparison to occur in the query, only returning users whose last sign-in exceeds the desired threshold. This approach requires that you write your own paged result handling code, which is beyond the scope of this blog.

When Is a User Inactive?

When you have multiple identity stores, you need to determine your definition of inactivity with respect to each store’s thresholds. In the simplest case, you’d use the same threshold for each identity store. For example, in the above examples we’re querying for users whose last logon is more than 90 days ago. If a user appears in both lists, we can classify them as inactive and take whatever action our policy dictates (disabling/deleting the account, stripping licenses, or potentially notifying the manager before disabling/deleting).

There is no requirement that the thresholds be the same for both AD and Azure AD, and you may want to adjust them based on the level of activity you expect in each directory. For users who access only one identity store, you may want to consider only the activity in that data store for determining inactivity.

Whatever your approach, you should consider running your system in a “report-only” mode initially. Have the system list the users it would disable or delete rather than acting. You can take this list and look for false positives (users who are active but appear in the list) and missed accounts (users who are inactive but not showing up). This will allow you to tweak your thresholds before going live. However, you must be prepared to accept that you will never have a perfect match and will need to balance the inconvenience to users against your security and cost recovery goals.

It’s generally easier to start with a less aggressive policy (e.g., disabling users after 180 days) and slowly tighten it, rather than going the other way. Tight policies can run afoul of the respective identity stores’ logging limitations, so implementing short time frames (e.g., under 45 days) needs to be done carefully and with understanding of your usage patterns.

Inactivity is often used to identify and clean up users who are no longer required, who have slipped through the user management process in some way. Most organizations support users who may be on extended leaves (maternity/paternity leave, disability, sabbatical, etc.) and who may not be expected to access systems regularly during that leave. Depending on your environment, disabling those accounts may be appropriate. In other cases, disabling may be the incorrect action (e.g., if it will block users from accessing HR/payroll systems). You should be prepared to identify and exclude such accounts from your inactivity system if they aren’t appropriate to be handled in such a manner.

In Conclusion

This blog explains how you can pull data from AD and Azure AD to identify inactive user accounts, including some of the factors you need to consider when determining if an account is inactive. Ideally, these approaches should be used to supplement a robust identity management solution. However, implementing these practices can at least be a stopgap until you have such a system in place.

If you need help implementing identity management systems for AD and Azure AD, please contact the experts at Ravenswood Technology Group!