SP-less Triage Loop
Four jobs over service-principal sign-ins whose service principal ID is all zeros. Scoping finds each new app and assigns a lane. Baselining records what the app normally looks like. Alerting re-checks every baselined app for drift, because the ConnectWise case was a known vendor whose stolen credential was used from new infrastructure. Investigating is where anything that fails a review goes: nothing is written to the kept list until it comes back benign. Public sources only; describes no particular tenant.
In plain terms. An outside app can ask your Microsoft tenant for a login token even though you never installed it. The log records these with an ID of all zeros and calls them "Success", but the token carries no permissions, so most Microsoft services refuse it. This page finds every such app, sorts it into Microsoft, a vendor you know, or nobody's, records what normal looks like, re-checks daily, and investigates anything new. The only way to stop one is to create its identity in your tenant and switch it off. Names prove nothing; Microsoft's documentation or a vendor's exact domain does.
Four jobs, three human gates
| Job | Runs | Does | Human gate |
|---|---|---|---|
| Scoping | Daily, scheduled | Pulls all-zeros service-principal sign-ins, groups by appId and owner tenant, alerts on any appId not in the kept list. The lookup is one Graph call per owner tenant (findTenantInformationByTenantId) and can be scripted. | Lane assignment. The Microsoft-set test and the exact-domain match are mechanical; "this vendor is ours" and the lane 3 second analyst are not. |
| Baselining | On demand, once per laned appId | Baselines 90 days of all that app's sign-ins, zeros or real service principal, and appends one row to the kept list: lane, credential key IDs, resources, ASNs, first seen. | Every new app: the sets are reviewed by hand before the row is written, because the 90 days behind a newly seen app may already contain the attacker. Not clean → Investigating; the row is not written. Day one is only the day there are many. |
| Alerting | Daily, scheduled | Reads the kept list, pulls the last two days of sign-ins for every baselined appId, and alerts on any credential key ID, resource or ASN outside the stored sets, and on failures. | None. Anything new → Investigating. Alerting never writes the kept list. |
| Investigating | On demand, from either exit | Issuance-then-use (did the all-zeros token get used: 2xx Graph activity from the same appId), persistence audit (roles, credentials, add→remove→add churn), blast radius (resources, mailboxes, sibling principals touched). The three queries and the containment steps are below. | The verdict. Benign: write the row or append the new value, by hand. Malicious: IR case, contain (create-then-disable the service principal, revoke what it touched), mark the row compromised and never append. Nothing rebuilds the baseline from raw data, so an attacker's ASN is never baselined the day after first use. |
What this loop can and cannot see
- Scope. The all-zeros filter shows apps that have no service principal in your tenant. A vendor app you consented to has a real one and never appears here. For ConnectWise Cloud Backup that means Scoping sees the exposure in tenants that never used the product; in customer tenants only Alerting, keyed on appId regardless of service principal ID, sees the stolen credential being used from new infrastructure.
- Prior. Microsoft built the exception list from observed traffic. Its Entra blog says resource apps reached by SP-less clients were frozen in April 2025 and traffic "observed between February 11 and March 11, 2025" was allowed through; a second freeze in August 2025 covered three Microsoft resources (Exchange Online, AAD Graph, ARM) and six third-party resource apps, keeping clients that averaged more than one request a day; and the timeline says March 31, 2026 blocks "all client apps without a service principal ... including the ones previously allowed to continue working". The monitoring FAQ, updated since, still says tokens are issued "except for a few Microsoft and non-Microsoft applications". Microsoft has not published that list. A non-Microsoft owner with all-zeros successes today is on it: rare and high-prior, not routine. Both freezes were scoped by resource, so read the exception as a client-and-resource pair, which is why Scoping keeps the resource column.
- Success is issuance. A "Success" row in the service-principal sign-in log records that Entra cut the token. Whether the resource honoured it is a separate question, answered below, and for every resource except Graph the tenant has no log of the answer.
- No preventive layer. Workload-identity Conditional Access cannot target an app with no service principal, and Microsoft's workload-identity page says policy "can be applied to single tenant service principals that are registered in your tenant" while "Microsoft and third-party SaaS applications, including multitenant apps, are not covered". Lanes 1 and 2 are detection only; lane 3 is the only control.
- Expected gaps. Microsoft's monitoring FAQ says some Graph calls "are first-party, app-only calls, which aren't published to the Service Principal sign-in logs", so Graph activity you cannot match to a sign-in is not evidence either way.
What an SP-less token is, from public sources
| Claim | Consented app-only token (Microsoft's sample) | SP-less token |
|---|---|---|
appid (v1.0) or azp (v2.0) | The client's application ID (claims reference: "The application ID of the client using the token"). | Present. It is the only identity in the token, and it is what the sign-in record prints as appId (AADServicePrincipalSignInLogs, column AppId). |
oid, sub | The client's service principal object ID in your tenant; both carry the same GUID in Microsoft's sample app-only token ("oid": "67cb0334-…", "sub": "67cb0334-…"). | Absent. The Entra blog: SP-less authentication "issues tokens without permissions and without an object identifier (object ID)". A Microsoft moderator on Q&A (2022): oid and sub "will be null and not returned in the token" when "its service principal is missing in that tenant". |
roles | The application permissions your admin consented to; the same sample shows "roles": ["ServiceHealth.Read", "ActivityFeed.Read"]. The claims reference: the client credential flow "uses this set of permission in place of user scopes for application tokens". | Absent; there is no consent to copy from ("without permissions", Entra blog). The claims reference: "Claims are present only if a value exists to fill it." |
tid, aud, iss, exp | Your tenant, the resource, the issuer, the expiry (claims reference; all four appear in the sample). | Present and ordinary. tid is your tenant, which is why the row lands in your log (retirement page: shown "in the sign-in logs of the resource tenant"). |
The consented sample is the app-only token on Microsoft's Office 365 Management APIs getting-started page. Nobody has published a decoded SP-less token, so the right-hand column is Microsoft's own description plus the claims reference, not a capture.
| Resource | What it does with an SP-less token | Tenant-side record |
|---|---|---|
| Microsoft Graph | Entra issues the token; Graph evaluates the empty permission set per request. Inforcer, Oct 17: "Microsoft Graph can still return a token", and "the token carries no permissions". | The sign-in row, plus Graph activity logs if enabled. A 2xx there is the Investigating trigger. |
| Exchange Online, SharePoint Online | Inforcer: both "reject the request with a 'missing service principal' error". | The sign-in row only. A rejected request writes no mailbox or file audit. |
Office 365 Management APIs (manage.office.com; appId c5393580-f805-4401-95e8-94b7a6ef2fc2 per the community first-party list) | Needs tenant-admin consent (getting started: "An Office 365 tenant admin must explicitly grant consent") and a token carrying ActivityFeed.Read; without it Microsoft's error AF10001, "the permission set sent in the request did not include the expected permission" (Activity API reference). Huntress (Oct 17, 5:15 PM ET update) names this API among the targets of the Oct 15 manual sweep. | The sign-in row only. The API reference documents no customer-visible log of who called it. |
The resource decides, and for everything except Graph the tenant never sees the decision. That is why Investigating starts from the Graph activity log and reads the other resources' sign-in rows as attempts.
Admission rules
| Lane | Evidence required | What it is not |
|---|---|---|
| Lane 1 · Microsoft | Owner tenant is one Microsoft documents, or the appId appears in Microsoft's service principal reference table. | Not a tenant whose displayName says Microsoft. Names are free text; a lookup can demote, never promote. |
| Hold | Owner field null; lookup returned nothing (other national cloud, 404); Microsoft-named but undocumented; domain is *.onmicrosoft.com (every tenant's initial domain, so it names nobody) or a lookalike. | Not ignored, not disabled. Resolve by appId against Microsoft's table and the community list, or ask Microsoft. |
| Lane 2 · Vendor | defaultDomainName exactly equals the vendor's registrable domain, or the appId is one the vendor publishes. | Not "a name I recognise". Lane 2 is detection only, so it earns Alerting every day. |
| Lane 3 · Nobody | Lookup answered, nobody claims the app, second analyst agrees. Create a service principal for the appId, disable it, verify via Graph that it exists with accountEnabled=false, record its objectId. | Not the branch for a failed lookup. Undo is deleting the service principal; re-enabling it leaves a consented app behind. |
Every newly laned app gets the full lookup and a hand review of its Baselining sets before the row is written. Day one is the same procedure at volume, with one extra rule: nothing goes to lane 3 on day one.
The Microsoft set
| Owner tenant | What Microsoft calls it | Examples Microsoft lists |
|---|---|---|
f8cdef31-a31e-4b4a-93e4-5f571e91255a | "the Microsoft Service's Microsoft Entra tenant ID" | None named on the page; it appears as a screenshot caption. |
72f988bf-86f1-41af-91ab-2d7cd011db47 | tenant of "Microsoft tenant-owned applications", listed as "some, but not all" | Graph Explorer, Microsoft Graph Command Line Tools |
Two is what Microsoft documents, not a claim that two is all. Microsoft's service principal reference table says it "is not exhaustive and will grow over time". The set grows only by Microsoft documentation.
Queries
Scoping · daily inventory, bucketed in-query
index=* category=ServicePrincipalSignInLogs earliest=-90d@d
properties.servicePrincipalId="00000000-0000-0000-0000-000000000000"
| rename properties.appOwnerTenantId as owner_tenant,
properties.appId as app_id, properties.appDisplayName as app_name
| fillnull value="-" owner_tenant app_name
| eval bucket=case(
in(owner_tenant, "f8cdef31-a31e-4b4a-93e4-5f571e91255a",
"72f988bf-86f1-41af-91ab-2d7cd011db47"), "microsoft",
owner_tenant="-", "hold: no owner on record",
true(), "resolve")
| stats count min(_time) as first max(_time) as last
values(properties.resourceDisplayName) as resources
by bucket owner_tenant app_id app_name
| sort bucket
Bucket is pre-triage, not the lane. "resolve" rows go through the lookup and the admission rules. Add | where NOT [ inputlookup spless_kept.csv | fields app_id ] on the scheduled run so only unbaselined appIds alert.
Baselining · once per laned appId
index=* category=ServicePrincipalSignInLogs earliest=-90d@d
properties.appId="<appId being baselined>"
| rename properties.appId as app_id,
properties.servicePrincipalCredentialKeyId as key_id,
properties.resourceDisplayName as resource,
properties.autonomousSystemNumber as asn
| fillnull value="-" key_id resource asn
| stats values(key_id) as key_ids values(resource) as resources
values(asn) as asns min(_time) as first_seen by app_id
| eval owner_tenant="<owner tenant>", lane="<1|hold|2|3>",
key_ids=mvjoin(key_ids,"|"), resources=mvjoin(resources,"|"),
asns=mvjoin(asns,"|")
| outputlookup append=true spless_kept.csv
No zeros filter: the baseline covers the app under a real service principal too. Key ID, resource and ASN are the ServicePrincipalCredentialKeyId, ResourceDisplayName and AutonomousSystemNumber columns. Review the sets by hand before writing, for every new app; that review is where an attacker already in the 90 days gets caught.
Alerting · daily drift on baselined appIds
index=* category=ServicePrincipalSignInLogs earliest=-2d@d
[ inputlookup spless_kept.csv | fields app_id | rename app_id as properties.appId ]
| rename properties.appId as app_id,
properties.servicePrincipalCredentialKeyId as key_id,
properties.resourceDisplayName as resource,
properties.autonomousSystemNumber as asn, resultType as result
| fillnull value="-" key_id resource asn result
| dedup properties.id
| stats count by app_id key_id resource asn result
| lookup spless_kept.csv app_id OUTPUT key_ids resources asns
| eval why=mvappend(
if(isnull(mvfind(split(key_ids,"|"), "^".key_id."$")), "new keyId", null()),
if(isnull(mvfind(split(asns,"|"), "^".asn."$")), "new ASN", null()),
if(isnull(mvfind(split(resources,"|"),"^".resource."$")),"new resource", null()),
if(result!="0", "failure ".result, null()))
| where isnotnull(why)
| table app_id why key_id resource asn result count
The kept list stores each set pipe-delimited. No success filter: a failed sign-in for a baselined vendor appId from a new ASN is the signal, and error-code shifts (AADSTS7000215 invalid client secret, AADSTS7000222 expired client secret, AADSTS7000112 application disabled) mean someone is testing credentials. Two-day window with dedupe covers export lag. Same cloud provider as the vendor is not clearance.
Microsoft's own inventory call, from its monitoring FAQ: GET https://graph.microsoft.com/beta/auditLogs/signIns?$filter=signInEventTypes/any(t: t eq 'servicePrincipal') and servicePrincipalId eq '00000000-0000-0000-0000-000000000000'. Beta is required; the v1.0 signIn resource has no signInEventTypes, servicePrincipalId or appOwnerTenantId; all three are beta. Field names above follow the Azure Monitor export shape; the Log Analytics columns are documented, the exact event-hub keys are inferred from them, so tune to your add-on. Splunk commands and functions used: stats, lookup, inputlookup, outputlookup, fillnull, dedup, transaction, rex, mvfind and mvjoin, in() and case().
Investigating · queries for one appId
Issuance-then-use
index=* category=MicrosoftGraphActivityLogs earliest=-30d@d
properties.appId="<appId under investigation>"
| rename properties.responseStatusCode as code, properties.requestUri as uri,
properties.requestMethod as method
| where tonumber(code)>=200 AND tonumber(code)<300
| stats count as ok2xx values(method) as methods values(uri) as uris
min(_time) as first max(_time) as last by properties.appId
→ any row = the app acted despite "no consent"; writes (POST/PATCH/DELETE)
against roles, servicePrincipals or applications go straight to the verdict
Prerequisite: Microsoft Graph activity logs must be enabled through diagnostic settings (P1 or P2); off means a coverage gap, not a clean result. Per Microsoft's FAQ, some first-party app-only calls are not published to the service-principal sign-in log, so Graph rows you cannot pair with a sign-in are not evidence either way. Columns: MicrosoftGraphActivityLogs.
# which resource each all-zeros token was cut for
index=* category=ServicePrincipalSignInLogs earliest=-30d@d
properties.appId="<appId under investigation>"
properties.servicePrincipalId="00000000-0000-0000-0000-000000000000"
| fillnull value="-" properties.resourceDisplayName properties.resourceId
| stats count values(resultType) as results
min(_time) as first max(_time) as last
by properties.resourceDisplayName properties.resourceId
→ Graph rows: read the block above. Exchange, SharePoint and the
Office 365 Management APIs reject an SP-less token and write no
tenant-side log of it; those rows are attempts, and the only record
The resource column is also the exception evidence: Microsoft's freezes were scoped per resource, so a client allowed through to one resource may be blocked at another. Issuance rows for a resource the vendor never documented using belong in the verdict.
Persistence audit
# two views of one appId: what it DID (actor) and what was done TO it (target)
index=* category=AuditLogs earliest=-30d@d ("<appId>" OR "<app display name>")
operationName IN ("Add member to role", "Remove member from role",
"Add app role assignment to service principal",
"Remove app role assignment from service principal",
"Add service principal credentials", "Remove service principal credentials",
"Update application – Certificates and secrets management",
"Add service principal", "Remove service principal", "Delete application",
"Add owner to application", "Add owner to service principal",
"Consent to application", "Add delegated permission grant")
| rename properties.initiatedBy.app.appId as actor_app,
properties.initiatedBy.app.displayName as actor_name,
properties.initiatedBy.user.userPrincipalName as actor_user,
properties.targetResources{}.displayName as target, properties.result as result
| eval side=if(actor_app="<appId>", "actor", "target")
| table _time side operationName actor_app actor_name actor_user target result
| sort _time
# churn: the same target gets an add AND a remove inside 60 min (operator or agent at the keyboard)
index=* category=AuditLogs earliest=-30d@d ("<appId>" OR "<app display name>")
operationName IN ("Add member to role", "Remove member from role",
"Add service principal credentials", "Remove service principal credentials")
| rename properties.targetResources{}.displayName as target
| eval verb=if(match(operationName, "^Add"), "add", "remove")
| transaction target maxspan=60m
| where mvcount(mvdedup(verb))=2
| table _time duration target operationName
→ actor rows: the app changed something (self-assigned a role, put a credential on another app)
→ target rows: someone changed the app (a credential added since the vendor's incident date is the way in)
→ sibling principals it touched go on the containment list
Operation names are Microsoft's audit activity reference names. The add → remove → add churn is what Huntress reported on Oct 26 ("A secret was added, removed, and added again"; the Global Administrator role granted then removed twice), and the self-assigned role is the Defender alert "Application self-assigned Global Administrator role" in Microsoft's Storm-0940 post.
Blast radius
# 1) resources the token was minted for, and from where
index=* category=ServicePrincipalSignInLogs earliest=-30d@d properties.appId="<appId>"
| stats count min(_time) as first max(_time) as last
by properties.resourceDisplayName properties.ipAddress properties.autonomousSystemNumber
# 2) Graph: which object families, how many distinct users
index=* category=MicrosoftGraphActivityLogs earliest=-30d@d properties.appId="<appId>"
| rename properties.requestUri as uri, properties.requestMethod as method,
properties.responseStatusCode as code
| eval family=case(match(uri,"/messages|/mailFolders"), "mail",
match(uri,"/drives|/sites"), "files", match(uri,"/directoryRoles|/roleManagement"), "roles",
match(uri,"/servicePrincipals|/applications|/oauth2PermissionGrants"), "apps",
match(uri,"/users"), "users", match(uri,"/groups"), "groups", true(), "other")
| rex field=uri "/users/(?<user>[^/?]+)"
| stats count dc(user) as users values(method) as methods by family code
| sort family
# 3) mailboxes actually read (Microsoft 365 unified audit log; MailItemsAccessed is
# Audit (Standard), on by default for E3/E5 mailboxes — if absent, that is a gap, not a zero)
index=* Workload=Exchange Operation=MailItemsAccessed ClientAppId="<appId>" earliest=-30d@d
| stats count dc(MailboxOwnerUPN) as mailboxes values(ClientIPAddress) as ips by ClientAppId
→ the "users", "mail" and "files" rows and the mailbox count are the notification scope
MailItemsAccessed is part of Audit (Standard), "enabled by default for users that are assigned an Office 365 E3/E5 or Microsoft 365 E3/E5 license". In the mailbox audit schema the app is ClientAppId ("the Microsoft Entra app that performed the access"); AppId there is "the API pathway", for example Graph, so do not filter on it. The Graph URI families come from RequestUri.
Contain
- Stop new tokens. If the app has a service principal in your tenant, disable it (
accountEnabled=false). Disable, do not delete: deletion destroys the object the audit trail points at. If it has none, create one for the appId and disable it, which is Microsoft's documented block for SP-less apps: "Disabling the app's service principal will block all future sign-in and authentication attempts by that app in your tenant". - Accept the token tail. An issued access token runs to expiry (Microsoft: "the user loses access when the access token expires"; earlier revocation needs Continuous Access Evaluation on both client and resource), and the default lifetime is "a random value ranging between 60-90 minutes (75 minutes on average)" (token lifetimes). Disabling the service principal stops issuance, so the exposure window closes within that span. Watch the Graph activity log for the appId until it goes quiet.
- Undo what it did. From the persistence audit's actor rows: remove role assignments it granted (Global Administrator first), delete credentials it added to other applications or service principals, delete sibling service principals it created, revoke consent grants it added. Each of these is its own audit event, so the undo is itself auditable.
- Close the way in. From the target rows: remove any credential added to the app since the vendor's incident date. If the vendor holds the credential, the vendor rotates it; keep the service principal disabled until the vendor confirms.
- Users it reached. For every user in the blast radius, revoke sign-in sessions (
revokeSignInSessions, which invalidates refresh tokens while access tokens run to expiry), review inbox rules and mail forwarding, and reset credentials where the app had write scope on the user. - Put a control in front of it. Microsoft's retirement page says a service principal brings "the ability to target these apps individually with Conditional Access policies"; its workload-identity Conditional Access page says policy "can be applied to single tenant service principals that are registered in your tenant" and that "multitenant apps, are not covered". For a vendor's multitenant app, build the location policy in report-only and read the sign-in log's Conditional Access tab before relying on it; the disabled service principal is the control Microsoft documents as holding.
- Preserve, then record. Export the sign-in, audit and Graph activity logs for the window before portal retention (30 days on P1 and P2, seven on Free) drops them. Mark the kept-list row compromised with the case ID; it is never appended.
Containment order follows Microsoft's Storm-0940 post ("Do not delete enterprise applications as the first response"; preserve, review credentials and role assignments, then remove) and Huntress's Oct 16 guidance to disable the application. Undo for a lane 3 block is deleting the created service principal; undo for containment is the vendor's confirmed rotation plus a permission review, never a bare re-enable.
Prerequisites
- Lookup:
findTenantInformationByTenantId, permissionCrossTenantInformation.ReadBasic.All; Microsoft: it "doesn't require the calling user to be assigned any Microsoft Entra role". - Lane 3: Application Administrator or Cloud Application Administrator for a multitenant app;
New-MgServicePrincipal -AppIdneeds delegatedApplication.ReadWrite.All(app-only:Application.ReadWrite.OwnedByis least privileged). The creation emits an "Add service principal" audit event; allow-list the loop's own creations in consent alerting. - Retention: 90 days where your export keeps it; the Entra portal keeps 30 days on P1 and P2, seven on Free, and Graph activity logs are not retained at all unless exported.
- Never apply lane 3 to anything that appears in the preview
MicrosoftServicePrincipalSignInLogsstream; Microsoft's FAQ warns "Disabling them could result in losing access to your account".
Sources: Microsoft Learn, Retirement of service principal-less authentication; Monitoring and health FAQ; Verify first-party Microsoft applications in sign-in reports; service principal reference table (the first-party application table); AADServicePrincipalSignInLogs schema; findTenantInformationByTenantId; Entra blog timeline (the freezes, the observation windows and the "without permissions and without an object identifier" description); access token claims reference; Q&A, oid and sub missing when the service principal is missing (Microsoft moderator, 2022); Office 365 Management APIs getting started (admin consent, sample app-only token) and Activity API reference (the ActivityFeed.Read requirement, error AF10001); merill/microsoft-info (community first-party appId list, used only to label a resource); Inforcer, Oct 17 2026 (Graph issues, Exchange and SharePoint reject); Microsoft Security, Storm-0940, Oct 21 2026; Conditional Access for workload identities; configurable token lifetimes; revoke user access; data retention; audit log activity reference; Microsoft Graph activity logs and table schema; MailItemsAccessed and the Management Activity API schema; create servicePrincipal; AADSTS error codes; Huntress, Oct 16 2026 and Oct 26 2026; Chorus Cyber, Oct 19 2026. Flowchart symbols per ISO 5807. Companion to the ConnectWise verdict card. Marcus Liberto · source · public OSINT only.