{"id":52,"date":"2026-07-14T07:33:24","date_gmt":"2026-07-14T07:33:24","guid":{"rendered":"https:\/\/helpdesq.tech\/?p=52"},"modified":"2026-07-14T07:33:24","modified_gmt":"2026-07-14T07:33:24","slug":"how-i-cleaned-up-stale-guest-accounts-in-microsoft-entra-id-and-why-it-matters","status":"publish","type":"post","link":"https:\/\/www.helpdesq.tech\/?p=52","title":{"rendered":"How I Cleaned Up Stale Guest Accounts in Microsoft Entra ID \u2014 And Why It Matters"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">During a recent engagement with a client, I was tasked with reviewing their Microsoft Entra ID tenant as part of a broader identity security assessment. One of my first observations was the presence of a significant number of inactive guest accounts.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For context, guest accounts are typically assigned to external users invited to collaborate with an organization. In this case, many of these accounts were months\u2014or even years\u2014old and had either never signed in or had long since stopped accessing the tenant.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is a surprisingly common issue. Guest accounts are simple to create but often forgotten, and when left unmanaged, they present an unnecessary attack surface. Dormant accounts could potentially be compromised and exploited to gain access to internal resources.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The client needed an efficient way to identify, review, and clean up these accounts\u2014without manually checking each one. In this write-up, I will outline how I approached this project and offer actionable strategies for managing inactive guest accounts in your own environment.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The Approach<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Instead of performing a one-time manual cleanup, I created a reusable PowerShell script that connects to the tenant using the Microsoft Graph API and automates the entire process. Scripts like this not only save time but also ensure standardized implementation of such processes, allowing new team members to easily reuse the script in the future.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The script:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Identifies all guest users in the tenant. This includes all users with the guest userType attribute.<\/li>\n\n\n\n<li>Checks when each one last signed in using audit logs<\/li>\n\n\n\n<li>Flags anyone inactive beyond a configurable threshold (for this project, we used 90 days)<\/li>\n\n\n\n<li>Exports a CSV report for review<\/li>\n\n\n\n<li>Deletes stale accounts when approved \u2014 controlled by a dry run flag for safety<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Using the dry run flag was crucial in this process. It allowed the client to first review a comprehensive report and verify that the list of guest users was correct. Only after confirming the accuracy of this list did we proceed to live mode, which executed the deletion of stale accounts.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Prerequisites<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To replicate this in your own environment you will need:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>PowerShell 7.0 or higher<\/li>\n\n\n\n<li>Microsoft Graph PowerShell module<\/li>\n\n\n\n<li>An Entra ID app registration with the following API permissions:\n<ul class=\"wp-block-list\">\n<li>User.Read.All \u2014 This gives the script read access to all users in the tenant<\/li>\n\n\n\n<li>User.ReadWrite.All \u2014 Gives the script read and write access to all users (it\u2019s what makes deletion possible)<\/li>\n\n\n\n<li>AuditLog.Read.All \u2014 Gives the script access to the tenants audit logs<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The Script<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The full script is available on GitHub: github.com\/tmugema1\/stale-guest-user-cleanup<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here is the core logic:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em># Get all guest users<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>$guestUsers = Get-MgUser -Filter &#8220;userType eq &#8216;Guest'&#8221; -All -Property &#8220;displayName,mail,userPrincipalName,createdDateTime,id&#8221;<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em># Check sign-in activity and flag stale users<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>$today = Get-Date<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>$staleUsers = foreach ($guest in $guestUsers) {<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>&nbsp; &nbsp; $signIns = Get-MgAuditLogSignIn -Filter &#8220;userId eq &#8216;$($guest.Id)'&#8221; -Top 1 | Select-Object -First 1<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>&nbsp; &nbsp; if ($signIns) {<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>&nbsp; &nbsp; &nbsp; &nbsp; $lastSignIn&nbsp; &nbsp; &nbsp; = $signIns.CreatedDateTime<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>&nbsp; &nbsp; &nbsp; &nbsp; $daysSinceSignIn = ($today &#8211; $lastSignIn).Days<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>&nbsp; &nbsp; } else {<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>&nbsp; &nbsp; &nbsp; &nbsp; $lastSignIn&nbsp; &nbsp; &nbsp; = &#8220;Never&#8221;<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>&nbsp; &nbsp; &nbsp; &nbsp; $daysSinceSignIn = 999<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>&nbsp; &nbsp; }<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>&nbsp; &nbsp; if ($daysSinceSignIn -ge $thresholdDays) {<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>&nbsp; &nbsp; &nbsp; &nbsp; [PSCustomObject]@{<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Id &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; = $guest.Id<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; DisplayName&nbsp; = $guest.DisplayName<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Email&nbsp; &nbsp; &nbsp; &nbsp; = $guest.Mail<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; LastSignIn &nbsp; = $lastSignIn<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; DaysInactive = $daysSinceSignIn<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>&nbsp; &nbsp; &nbsp; &nbsp; }<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>&nbsp; &nbsp; }<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>}<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The Output<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The script exports a CSV report listing every stale guest account, their last sign-in date, and how many days they have been inactive. This gave the client a clear, auditable record of exactly who was removed and why. You could further customize the script to export the CSV file to a shared location such as SharePoint or OneDrive, automatically email it to an administrator.&nbsp;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What We Did Next<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">After the initial cleanup, I recommended the client put in place ongoing governance practices to prevent the problem from recurring. These include:&nbsp;<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Access Reviews:<\/strong> These should include scheduled quarterly or monthly reviews of all guest accounts using Microsoft Entra ID Governance<\/li>\n\n\n\n<li><strong>Lifecycle policies:<\/strong> Automatically disabling guests who haven&#8217;t signed in within 90 days using Entra ID Lifecycle Workflows to ensure stale accounts are actioned consistently without manual intervention.<\/li>\n\n\n\n<li><strong>Entitlement Management:<\/strong> Controlling how guests are invited and setting expiry on their access packages using Entra ID Entitlement Management. This approach ensures external users only retain access for as long as it is needed and are automatically removed when their access period expires.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Conclusion<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Stale guest accounts pose a common yet often overlooked security risk for many organizations. Fortunately, with the right tools, identifying and cleaning up these accounts can be straightforward. Automating this process ensures that it does not get neglected over time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A critical point to remember is that when automating with your script, you should always include a step to review the results before deleting any accounts. This reduces the already slim risk of accidentally removing active guest accounts that users may still rely on.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If your organization has ungoverned guest access or has recently flagged inactive external users in a security audit, I can help you assess the problem and implement a long-term solution. Connect with me on <a href=\"mailto:tom@helpdesq.tech\">tom@helpdesq.tech<\/a> to discuss how I can help.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>During a recent engagement with a client, I was tasked with reviewing their Microsoft Entra ID tenant as part of a broader identity security assessment. One of my first observations was the presence of a significant number of inactive guest accounts. For context, guest accounts are typically assigned to external users invited to collaborate with [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":53,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_eb_attr":"","footnotes":""},"categories":[1],"tags":[],"class_list":["post-52","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-uncategorized"],"_links":{"self":[{"href":"https:\/\/www.helpdesq.tech\/index.php?rest_route=\/wp\/v2\/posts\/52","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.helpdesq.tech\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.helpdesq.tech\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.helpdesq.tech\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.helpdesq.tech\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=52"}],"version-history":[{"count":1,"href":"https:\/\/www.helpdesq.tech\/index.php?rest_route=\/wp\/v2\/posts\/52\/revisions"}],"predecessor-version":[{"id":54,"href":"https:\/\/www.helpdesq.tech\/index.php?rest_route=\/wp\/v2\/posts\/52\/revisions\/54"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.helpdesq.tech\/index.php?rest_route=\/wp\/v2\/media\/53"}],"wp:attachment":[{"href":"https:\/\/www.helpdesq.tech\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=52"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.helpdesq.tech\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=52"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.helpdesq.tech\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=52"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}