Anyone With the Link

Anyone With the Link

All right class.

Somebody shared a file this week by picking the option at the top of the list, the one that says anyone with the link. It was the fastest way to get the file to a supplier and it worked immediately, which is exactly why people pick it.

That link is still working. It will keep working until somebody deliberately removes it, and nobody is going to.

Possession of the URL grants access to the file. No account, no password, no second factor, no device check. It bypasses the entire identity stack you spent years building: Conditional Access never evaluates it, your MFA policy is irrelevant, and there is no entry in SigninLogs because nobody signed in.

Two consequences follow, and the second is why this is a security post rather than a governance one.

The link outlives the person. Offboarding disables the account, revokes the sessions and reassigns the mailbox. It does nothing to the anonymous links that account created, because those links are not attached to the account. A leaver who spends twenty minutes generating links to the client list walks out with working access, and the checklist reports success.

The link also survives incident response. When you contain a compromised mailbox you reset the password, revoke the refresh tokens and force reauthentication. If the attacker created anonymous links to what they found, none of that matters and the links keep serving the data. It is exfiltration performed in advance and collected later, which is why this belongs in your post-compromise checklist next to mail forwarding rules.

What Exists, and Who Is Gone

Start with the inventory, sorted by the thing that matters.

// Anonymous links created in the window, orphaned ones first
let Lookback = 30d;                  // sweep window
let IdentityLookback = 14d;          // one full UEBA resync cycle
// Portable across the Log Analytics, advanced hunting and unified IdentityInfo schemas
let Directory = IdentityInfo
    | where TimeGenerated > ago(IdentityLookback)
    | extend Upn = tolower(coalesce(column_ifexists("AccountUPN", ""), column_ifexists("AccountUpn", "")))
    | where isnotempty(Upn)
    | summarize arg_max(TimeGenerated, IsAccountEnabled, Department, JobTitle, Manager) by Upn
    // lookup consumes the right-hand key, so Upn does not survive the join.
    // This marker does, and it is what tells you the creator was not found.
    | extend DirectoryHit = "Found"
    | project-away TimeGenerated;
// Links explicitly taken down inside the window
let Revoked = OfficeActivity
    | where TimeGenerated > ago(Lookback)
    | where Operation == "AnonymousLinkRemoved"
    | where isnotempty(UniqueSharingId)
    | distinct UniqueSharingId;
OfficeActivity
| where TimeGenerated > ago(Lookback)
| where RecordType == "SharePointSharingOperation"
| where Operation == "AnonymousLinkCreated"
| where UserId !~ "app@sharepoint"
| extend Creator = tolower(UserId)
| lookup kind=leftouter Directory on $left.Creator == $right.Upn
| extend OwnerGone = isempty(DirectoryHit) or IsAccountEnabled == false
| extend OwnerStatus = case(isempty(DirectoryHit), "Not in directory", IsAccountEnabled == false, "Disabled", "Active")
| extend StillLive = isempty(UniqueSharingId) or UniqueSharingId !in (Revoked)
| extend Estate = iff(Site_Url has "/personal/", "OneDrive", "SharePoint")
// Most anonymous links are created by Teams on the user's behalf, not by a
// person choosing to share. Separate the two or the output is all screenshots.
| extend ClientApp = tostring(column_ifexists("ClientAppName", ""))
| extend LinkPermission = extract(@"<Type>(\w+)</Type>", 1, tostring(Event_Data))
| extend LinkOrigin = case(
      SourceRelativeUrl has "Microsoft Teams Chat Files", "Teams chat attachment (automatic)",
      ClientApp has "Teams", "Teams, file already in OneDrive",
      "Deliberate share")
| project TimeGenerated, LinkOrigin, LinkPermission, OwnerStatus, Creator, Department, JobTitle, Manager,
          SourceFileName, SourceFileExtension, Estate, StillLive,
          Site_Url, SourceRelativeUrl, ClientIP, ClientApp, UserAgent, UniqueSharingId, OwnerGone
| sort by LinkOrigin asc, OwnerGone desc, StillLive desc, TimeGenerated desc

Your First Run Will Be Screenshots

Run query one and the top of the output will be dozens of files called somethjing like "Screenshot 2026-07-30 204221.png", created in bursts of fourteen in six seconds, all sitting in a folder called Microsoft Teams Chat Files. Nobody deliberately shared anything. They pasted images into a chat with a colleague.

This is worth understanding rather than filtering away, because it is a finding in itself.

OwnerGone sorts to the top and it is the whole point of the query. A live anonymous link created by a disabled account is access to your data held by somebody who no longer works for you, with no expiry and no owner to ask.

StillLive is honest rather than accurate. It only sees revocations that happened inside the same window, so a link created fifty days ago and removed forty days ago will still read as live. Widen the lookback if you want a cleaner answer, and treat the column as a lead either way.

Sharing operations on files that are named like credentials were covered in the password file post, so this query stays on the sharing mechanism rather than scoring the file name twice.

You Will Never Know Who Used It

Here is the part that changes how you should think about the risk.

When somebody opens an anonymous link, SharePoint records it under a generated identifier that looks like urn:spo:anon# followed by a long hash. That is not an identity. Microsoft generates it so the audit record has something in the user field, and it cannot be resolved back to a person. The same hash turns up across different addresses and sites over weeks, so it is a weak correlation handle at best.

Your complete forensic picture for an anonymous access is therefore an IP address, a user agent and a hash. If leadership asks who took the file, the question is unanswerable by design. Plan for that rather than discovering it mid-incident.

What you can measure is spread.

// Anonymous links being pulled, most widely circulated first
let Lookback = 30d;
OfficeActivity
| where TimeGenerated > ago(Lookback)
| where Operation == "AnonymousLinkUsed"
| where isnotempty(ClientIP)
| extend Country = tostring(geo_info_from_ip_address(ClientIP).country)
| extend Estate = iff(Site_Url has "/personal/", "OneDrive", "SharePoint")
| summarize Accesses = count(),
            FirstUse = min(TimeGenerated),
            LastUse = max(TimeGenerated),
            DistinctIPs = dcount(ClientIP),
            AnonSessions = dcount(UserId),
            IPs = make_set(ClientIP, 20),
            Countries = make_set(Country, 15),
            Agents = make_set(UserAgent, 10)
  by SourceFileName, SourceFileExtension, Estate, Site_Url, SourceRelativeUrl
| extend Circulation = case(DistinctIPs >= 10, "Widely circulated",
                            DistinctIPs >= 3, "Forwarded beyond the first recipient",
                            "Single source")
| project FirstUse, LastUse, Circulation, SourceFileName, SourceFileExtension, Estate,
          Accesses, DistinctIPs, AnonSessions, Countries, IPs, Agents, Site_Url, SourceRelativeUrl
| sort by DistinctIPs desc, Accesses desc

DistinctIPs is the number to read. A link sent to one supplier and opened from one office address behaves as intended. The same link opened from fourteen addresses across four countries was forwarded, pasted into a ticket, or posted somewhere it should not have been. geo_info_from_ip_address resolves the country in the query, because OfficeActivity gives you the address and nothing else.

The Shape That Looks Like Theft

One link is normal. Thirty links in twenty minutes is somebody packing.

// Bulk anonymous link creation, cross-referenced against what was then pulled
let Lookback = 14d;                  // detection window
let BurstWindow = 1h;                // creation must cluster this tightly to count
let BurstThreshold = 10;             // links per window before this reads as collection
let IdentityLookback = 14d;
let Directory = IdentityInfo
    | where TimeGenerated > ago(IdentityLookback)
    | extend Upn = tolower(coalesce(column_ifexists("AccountUPN", ""), column_ifexists("AccountUpn", "")))
    | where isnotempty(Upn)
    | summarize arg_max(TimeGenerated, IsAccountEnabled, Department, JobTitle, Manager) by Upn
    // lookup consumes the right-hand key, so carry a marker that survives it
    | extend DirectoryHit = "Found"
    | project-away TimeGenerated;
// Anything that was actually pulled through an anonymous link in the window
let AnonUse = OfficeActivity
    | where TimeGenerated > ago(Lookback)
    | where Operation == "AnonymousLinkUsed"
    | summarize UseCount = count(), UseIPs = dcount(ClientIP), LastPull = max(TimeGenerated)
      by SourceFileName;
OfficeActivity
| where TimeGenerated > ago(Lookback)
| where Operation == "AnonymousLinkCreated"
| where UserId !~ "app@sharepoint"
// Teams creates one of these per chat attachment, automatically. Fourteen
// screenshots pasted into a chat is not collection. Query one still inventories them.
| where not(SourceRelativeUrl has "Microsoft Teams Chat Files")
| extend Creator = tolower(UserId)
| extend Folder = replace_string(SourceRelativeUrl, SourceFileName, "")
| lookup kind=leftouter AnonUse on SourceFileName
| summarize LinkCount = count(),
            FilesPulled = dcountif(SourceFileName, isnotempty(LastPull)),
            TotalPulls = sum(UseCount),
            PullIPs = max(UseIPs),
            Sites = dcount(Site_Url),
            Folders = dcount(Folder),
            Files = make_set(SourceFileName, 30),
            CreatorIPs = make_set(ClientIP, 5),
            WindowStart = min(TimeGenerated), WindowEnd = max(TimeGenerated)
  by Creator, Burst = bin(TimeGenerated, BurstWindow)
| where LinkCount >= BurstThreshold
// A person clicking Share fourteen times takes minutes. A client doing it takes seconds.
| extend BurstSeconds = max_of(datetime_diff("second", WindowEnd, WindowStart), 1)
| extend LinksPerMinute = round(LinkCount * 60.0 / BurstSeconds, 1)
| where LinksPerMinute < 30 or Folders > 1
| lookup kind=leftouter Directory on $left.Creator == $right.Upn
| extend Ind_1 = iff(isempty(DirectoryHit) or IsAccountEnabled == false, "Creator disabled or gone from directory", "")
| extend Ind_2 = iff(FilesPulled > 0, strcat(FilesPulled, " of these files were pulled anonymously"), "")
| extend Ind_3 = iff(Folders >= 3, strcat("Pulled from ", Folders, " different folders"), "")
| extend RiskIndicators = trim(@"\s\|\s*$", strcat(
      iff(isnotempty(Ind_1), strcat(Ind_1, " | "), ""),
      iff(isnotempty(Ind_2), strcat(Ind_2, " | "), ""),
      iff(isnotempty(Ind_3), strcat(Ind_3, " | "), "")))
| project WindowStart, WindowEnd, Creator, Department, JobTitle, Manager, IsAccountEnabled,
          LinkCount, LinksPerMinute, Folders, Sites, FilesPulled, TotalPulls, PullIPs,
          RiskIndicators, Files, CreatorIPs
| sort by FilesPulled desc, Folders desc, LinkCount desc

The combination to chase is a burst where FilesPulled is greater than zero. Creating thirty links is careless. Creating thirty links that are then pulled through is a completed exfiltration, and the timestamps tell you the order it happened in.

Folders and LinksPerMinute are what separate a person from a client. Somebody clicking Share, choosing Anyone and copying the URL fourteen times takes minutes and moves between folders. A sync client or a bulk upload does it in seconds from one place, which is why anything above thirty links a minute from a single folder is dropped. Migration tooling and bulk publishing still clear the threshold legitimately, so those accounts belong in an exclusion list rather than in a raised number.

What You Should Do Next

Change the default link type before anything else. If Teams chat attachments are producing anonymous links, your tenant default is Anyone, and that setting is generating more anonymous links per week than any person ever will. Set the default to Specific people, and set the default permission to View rather than Edit. Sharing still works, users notice nothing, and the volume in every query here collapses to the deliberate shares that actually deserve review.

Then decide whether Anyone links are allowed at all. In the SharePoint admin centre, external sharing has four levels and Anyone is the most permissive. Dropping to New and existing guests keeps sharing working and makes every query here return nothing.

If you keep it, set an expiry. Anonymous links support a maximum lifetime. Thirty days converts a permanent hole into a temporary one, and it is the highest value change available if you cannot turn the feature off.

Run query one and start with OwnerGone. Live links created by disabled accounts are the shortest path from this post to something worth fixing, and they will exist.

Then add link revocation to offboarding. Disabling the account is not containment while the links survive it. Same during incident response: if you contained a mailbox compromise this year without checking what was shared anonymously, that containment was incomplete.

Class dismissed.

Consent Preferences