We needed to get Entra ID risk detections into Splunk for correlation with our other security logs. The official Microsoft documentation is a maze of Graph API permissions and vague steps. Here's the concrete, working method we landed on after a week of trial and error.
We ruled out the Splunk Add-on for Microsoft Cloud Services because it felt too heavy for just this one signal. Instead, we used a simple Azure Function (PowerShell) triggered by a Logic App that listens to the Graph API risk detection feed. The function formats the events and sends them via HTTP Event Collector (HEC).
The core pieces:
* **Entra App Registration:** Needs `IdentityRiskEvent.Read.All` (Application permission) and admin consent.
* **Logic App:** A recurring HTTP poller that calls the `/identityProtection/riskDetections` endpoint, handling the `@odata.nextLink` for pagination.
* **Azure Function:** Receives the batch from the Logic App, transforms the JSON, and forwards to Splunk.
Here's the key part of the PowerShell function that shapes the data. We stripped out a lot of nested properties to make Splunk parsing sane.
```powershell
# Sample transform within the Azure Function
foreach ($riskEvent in $body.value) {
$splunkEvent = @{
time = [datetime]::Parse($riskEvent.detectedDateTime).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
host = $riskEvent.userPrincipalName
source = "EntraID.RiskDetection"
sourcetype = "_json"
event = $riskEvent | Select-Object id, userPrincipalName, riskEventType, detectedDateTime, riskLevel, riskDetail, ipAddress, location
}
# Send to Splunk HEC
Invoke-RestMethod -Uri $splunkHecUri -Method Post -Headers $headers -Body (ConvertTo-Json $splunkEvent -Depth 2)
}
```
Pitfalls we hit:
* The Graph API filter query syntax is finicky. You can't filter on all properties you might want.
* Timestamp management is critical. Store the last processed `detectedDateTime` in Azure Table Storage to avoid duplicates on each poll.
* The `riskDetail` property is where you find the real meat, like `"userPerformedSecuredPasswordChange"`, but the enum mapping isn't always obvious.
* Cost: The Logic App executions and Graph API calls are negligible for us (~10k users), but monitor it.
Now we can correlate a `riskySignIn` from Entra with a weird outbound firewall hit in the same 10-minute window. It works, but setting it up felt more like plumbing than engineering.
Build once, deploy everywhere
Interesting approach, but did you factor in the long-term cost of that Azure Function and recurring Logic App execution? It's often overlooked.
While you avoided the Splunk add-on's overhead, you've introduced a small but persistent Azure consumption cost. A pure Logic App polling solution can sometimes get expensive if you're polling frequently due to connector operation costs. Did you consider using an Event Grid trigger to push events to a more cost-effective endpoint, like a Storage Queue, before processing?
Also, for the `IdentityRiskEvent.Read.All` permission, note that this API endpoint is now part of the deprecated "risk detections" API. Microsoft is pushing everyone towards the newer `riskDetections` resource under `/identityProtection/riskDetections`, which uses a different data model. Your method might need a refactor soon.
Less spend, more headroom.
Right, the newer riskDetections API. That's exactly why rolling your own integration on Microsoft's graph is a bad idea. They shift the data model or deprecate endpoints whenever they feel like it, and your custom solution breaks. The Splunk add-on, for all its bloat, at least has a vendor team supposedly keeping up with those changes. You're trading a known cost for hidden support debt.
Just saying.
Smart to strip nested properties. That Graph API JSON is a horror show for Splunk schemas.
But you're paying for two services (Logic App + Function) to basically move and modify data. Have you considered dropping the Function entirely? A Logic App can handle the transform with a `Parse JSON` action and push directly to HEC. Saves the compute cost, and one less moving part to fail.
Also, watch that Logic App's run duration. If your polling frequency or data volume grows, you can hit the 2-minute timeout for the Consumption plan.
- elle
You're right about dumping the extra Function cost. I tried a pure Logic App transform last year and hit a wall with the `Parse JSON` action when dealing with the array of nested risk events. The action's strict schema requirement choked on some optional Graph fields, causing the whole run to fail.
Your timeout warning is spot on, especially if you're polling for a large tenant. I ended up moving the transform into an inline Code step (JavaScript) within the Logic App, which was more forgiving. Still cheaper than a separate Function, but not as clean as Parse JSON would've been.
Anyone know if the Parse JSON action's gotten better with complex, variable schemas recently?
Spreadsheets > marketing slides.
You're correct about the deprecated API, that's the biggest flaw. Reworking for the new `/identityProtection/riskDetections` endpoint changes more than the data model - the query parameters and pagination are different too.
On the cost, we tracked it. For our polling interval, the Logic App and Function combined cost less per month than the Splunk add-on's license uplift. The hidden cost isn't Azure, it's the engineering hours when Microsoft changes the Graph API version and breaks the auth or response format, which happened twice last year. Event Grid isn't an option for these risk signals, they only support audit logs.
Show me the query.
That's a solid working script. The nested property stripping is key - Splunk's Common Information Model for risk events really chokes on the full Graph schema.
One thing I'd add to your function: a deduplication check. The Graph API's delta queries can sometimes resend the same risk detection within a short window if its risk score fluctuates. We added a simple check against the last 5 minutes of ingested `riskId` values in a small Azure Table storage to avoid sending duplicates to HEC and bloating our Splunk license.
Cloud cost nerd. No, I don't use Reserved Instances.
Great point about the deduplication, I've seen those duplicate riskId entries too. Did you run into any issues with the Table storage latency? I tried a similar approach but switched to using a simple in-memory cache in the Function because our checks needed to be faster than Table storage's typical 1-2 second read time.
Also, curious if you're handling the scenario where the same riskId gets a new risk level update - are you still treating that as a duplicate, or letting it through as a new event?
Learning by breaking