Hero image generated by ChatGPT
This is a personal blog and all content herein is my own opinion and not that of my employer.
Enjoying the content? If you value the time, effort, and resources invested in creating it, please consider supporting me on Ko-fi.
This post is the long version of an idea that started as a small itch and escalated into a full blown tool.
OID-See is basically BloodHound for OAuth in Entra.
It maps third party OAuth apps, service principals, consent, scopes, assignments, and trust signals into a graph so you can see abuse paths and impersonation risk.
No SaaS, no consent to my app. You run it yourself, get a JSON, and visualise OAuth sprawl in a browser only visualiser. Nothing is sent out of your browser.
Also yes, the name is intentional. It is the side-eye you should be giving your OAuth apps.
If you are an Entra admin, a cloud security engineer, or someone who has ever said “it is just a harmless SSO integration”, welcome.
What this post covers
- The rabbit hole: consent screens, config blobs, and why I started pulling them apart
- The “identity laundering” moment when the metadata lies, but technically tells the truth
- Turning a bunch of awkward Graph calls into something you can reason about as a graph
- Scanner design: Graph-only by default, with optional enrichment for domains and credentials
- Scoring: why “offline_access is impersonation” is wrong, and what to score instead
- The awkward edge cases that made me swear at Microsoft, loudly
TL;DR
OAuth sprawl is a graph problem, not a table problem. The key risks I care about are:
- Impersonation: delegated permissions that let an app act as a user across juicy resources
- Over-privilege: too many scopes, or privileged scopes that should be rare
- App permissions: app roles and application permissions that represent real blast radius
- Persistence: refresh tokens, long lived credentials, federated creds, and stealthy scope creep
- Reachability: apps that do not require assignment are exposed by default
- Trust signals: verified publisher, identity laundering heuristics, and reply URL weirdness
- Governance: owners, assignment models, and whether anyone is actually responsible
That is what OID-See is built to map. Everything else is supporting evidence.
How it started: staring at consent screens like a weirdo

I have spent a lot of time modelling Conditional Access and device trust abuse. That work naturally drags you into OAuth, because OAuth is the bloodstream of M365.
At some point you end up with the same recurring problem:
- You have 200 plus service principals in even a brand new tenant.
- Some are Microsoft. Some are third party. Some are both, depending on which field you trust today.
- Every one of them wants permissions.
- Every one of them claims to be reputable.
- Half of them do not even have an owner.
If you try to tackle this as a spreadsheet, you will suffer. You will also miss the interesting bits, because the interesting bits are the relationships.
So I started where most of us start: the consent dialog.
The consent dialog is a UI, but it is also data
When you hit the Entra consent experience, a big JSON blob is handed down to the browser. It includes what the UI is about to show you:
- Display name
- Publisher
- Website
- Reply URLs
- Scopes with human friendly descriptions
- Flags like “user can consent as admin”
- The postback endpoint for the consent decision
It is not shocking that the blob matches the UI. That is literally why it exists.
But it is useful because it exposes fields that people do not routinely think about, like how many reply URLs a single app might have, or what schemes those URLs use.
And in the middle of a totally normal consent experience, I saw broker schemes like:
brk-multihub://...brk-<guid>://...
If your first thought was “that looks like NAA/BroCI”, same.
That is not the dangerous part. The dangerous part is that the consent experience teaches people to stop caring about reply URLs because they look boring.
Reply URLs are not boring. Reply URLs are your redirect boundary. And redirect boundaries are where attackers live.
So I started collecting config blobs for a few apps and comparing them with what Graph reports for the corresponding service principals.
Two examples from the early days were:
- officeatwork (verified publisher)
- Lucid (not verified in my test tenant at the time)
You can probably guess what happened next.
The token side quest that I did not want, but could not ignore
I am not doing token replay research here. OWASP has been warning about token leakage for years.
But I do care about what tokens expose because it affects what security products can detect, and it affects how we communicate risk.
On iOS Safari Web Inspector will happily show you lots of things, but not always the Authorization headers you want. On a laptop, life is easier.
In the meantime I captured what I could.
For officeatwork, I captured an access token that looked like a very normal Graph access token, with the usual claims:
audis Graphappidis the appscpshows delegated scopes likeUser.Read,openid,profile,email,offline_accessamrshows the auth methods used, including MFA
Nothing earth shattering. But it confirmed something important for the tool:
The key facts we care about are already available through Microsoft Graph.
Tokens are interesting for research and blog posts. They should not be a dependency for a scanner.
So I kept OID-See Graph-first, and treated token and consent blob evidence as optional enrichment, not core data.
That decision saved me a lot of pain later.
The first “WTAF Microsoft” moment: identity laundering
This is where it got spicy.
I pulled the service principal for Lucid and saw:
publisherNamewas “Microsoft Accounts”appOwnerOrganizationIdwas9188040d-6c67-4c5b-b112-36a304b66dadsignInAudiencewasAzureADandPersonalMicrosoftAccountverifiedPublisherwas null
If you have done any amount of Entra spelunking, you recognise that tenant ID.
9188040d-6c67-4c5b-b112-36a304b66dad is the “Microsoft Accounts” tenant.
So Graph is telling me that the “app owner” is Microsoft Accounts. For a third party app. Cool cool cool.

Then I looked at sign-in log fields and saw a similar shape:
- “App Owner Tenant ID” looks like Microsoft Accounts
- “Resource Owner Tenant ID” looks like Microsoft Services
- Home tenant and resource tenant are my tenant
It is like the data is saying: “trust me bro”.
What is actually happening?
A bunch of third party multi-tenant apps rely on the Microsoft consumer identity infrastructure in ways that cause these fields to present as Microsoft-y.
Sometimes that is completely benign. Sometimes it is a genuine trust signal. Sometimes it is just confusing.
But from a defender point of view, it creates an opportunity for misdirection. If your detection logic or your admin instincts treat “Microsoft Accounts” as inherently trusted, you have a problem.
This is why I started using the phrase identity laundering in this project.
Not because Microsoft is evil, but because the metadata surface makes it easy for an app to look Microsoft-shaped even when it is not Microsoft.
It is also a neat pun, and I am weak.

The uncomfortable conclusion
If those fields are not reliable for third party provenance, what is?
Within Graph-only constraints, your best “source of truth” is:
- verifiedPublisherId when present
- reply URLs
- homepage, terms, privacy URLs
- servicePrincipalNames
- tags that indicate integrated or brokered behaviours
- whether the resource scope is Graph or other first party APIs
Outside Graph, you can add:
- RDAP/WHOIS registrant data
- DNS, NS, and ASN context
- certificate CT logs if you feel spicy
OID-See is designed so you can start with Graph-only and then layer in enrichment - in fact, I made it so efficient that the enrichment is now default behaviour (but you can disable it if you don’t want random scripts making DNS etc calls).
The real problem: OAuth sprawl is hard to reason about
Even in a small tenant, you have a pile of objects:
- service principals
- application objects (sometimes you have them, sometimes you do not)
- app role assignments
- delegated permission grants
- OAuth2 permission grants
- directory roles and role assignments
- group memberships
- owners
- credentials
If you try to eyeball this, you will miss the abuse paths.
A graph makes the relationships obvious:
- This app has these delegated scopes on Graph
- That app has application permissions on SharePoint
- This group is assigned to that app
- This user is in that group
- This service principal is owned by nobody
- This app does not require assignment, so reachability is broad by default
You stop thinking “is this risky in isolation?” and start thinking “what can this become when chained?”
That is BloodHound’s secret sauce, and it maps nicely to OAuth.
So I started sketching a model.
My initial model: nodes and edges that matter
Nodes:
- ServicePrincipal
- Application (best-effort)
- User, Group, Role
- Resource service principals like Microsoft Graph
- Domain nodes for reply URL analysis (optional)
Edges:
- HAS_SCOPES (delegated)
- HAS_APP_ROLE (application permissions)
- ASSIGNED_TO (user/group assignment)
- OWNS (owners)
- MEMBER_OF (group membership)
- HAS_ROLE (directory roles)
- CAN_IMPERSONATE (derived)
- PERSISTENCE_PATH (derived)
- EFFECTIVE_IMPERSONATION_PATH (derived)
As you can tell, some edges are “facts” and some are “inferences”.
Facts come from Graph. Inferences come from scoring and path logic.
OID-See keeps those separate so you can always ask: “what did Graph actually say?”
Building the viewer: sick UI, but not the point
The viewer was the fun part. It is also the part that will not save you if the scanner lies.
I built a browser-only visualiser, with a few lenses:
- Sprawl view
- Risk view
- Paths view
- Query presets so you can zoom straight to “show me privileged delegated scopes” or “show me apps with no assignment required”
There were some classic graph rendering headaches:
- physics overlap when you change lenses
- spacing config that is fine after you tweak a dropdown, but not on first render
- “reset view” button styling that refuses to match the rest of the header bar
All solvable. Not the interesting bit.
The interesting bit was making sure the scanner exports data that is consistent, predictable, and useful.
So I moved the majority of effort into the scanner.
Why a graph alone was not enough
I went into this fully intending to build “just” a graph.
In my head, OID-See was going to be:
- Export Graph data
- Render nodes and edges
- Let people eyeball abuse paths
- Job done
That lasted right up until I tried to use it against a real tenant.
The problem was not that the graph was wrong. The problem was that graphs are cognitively and computationally expensive when you are dealing with hundreds or thousands of objects.
A graph is fantastic for answering questions like:
- “How does this app relate to that resource?”
- “What happens if this service principal is compromised?”
- “Is there a path from delegated consent to directory role exposure?”
But it is terrible at answering first-order triage questions like:
- “Which apps should I look at first?”
- “Why is this app considered risky?”
- “Is this noisy or genuinely interesting?”
- “What changed since last week?”
I found myself doing this awkward dance:
- Scan the tenant
- Load the graph
- Zoom around randomly
- Spot something odd
- Lose it again because the layout reflowed
- Repeat until mild rage

At that point it became obvious that a graph without structure is just chaos, but pretty.
Humans do not think in nodes and edges first
What I actually wanted, as a human, was:
- A ranked list of “these look spicy”
- A short explanation of why
- The ability to drill in visually after I had context
- Multiple ways to look at the same data depending on what question I was asking
That is why OID-See grew views beyond the graph.
Not to replace the graph, but to support it.
The graph is the truth layer, not the UX layer
The mental model I eventually settled on was this:
- The graph is the source of truth.
- The scanner is the product.
- The views are lenses over the same underlying data.
So instead of asking users to start with the graph, I flipped it around.
You start with things like:
- Risk summaries
- Tiered exposure counts
- Permission breakdowns
- “Why is this high risk?” explanations
- Tables you can sort and filter without physics engines getting involved
Then, when something looks interesting, you jump into the graph to answer the deeper question:
“Show me how this actually connects.”
This also had a nice side effect. It forced me to make the scoring explainable, because you cannot hide behind node spaghetti in a table view.
If the UI says “High risk”, it has to tell you why in plain English:
- Privileged delegated scopes
- Broad reachability
- No owners
- Persistence signals
- Identity laundering heuristics
If it cannot explain itself, the score is wrong.
Large tenants made the decision for me
The final nail in the coffin for “graph only” thinking was running this against a genuinely large tenant.
Thousands of nodes. Thousands of edges.

Even with truncation, clustering, and physics tweaks, a full graph render becomes something you visit, not something you live in.
But the moment I added:
- Summary dashboards
- Risk matrices
- Ranked tables
- Filter-first workflows
- An exec-friendly HTML report generated from the same JSON
the tool suddenly became usable at scale.
Same data. Same scanner. Same graph underneath.
Just presented in a way that respects how humans actually work.
The punchline
OID-See is not “a graph tool with some tables”.
It is a graph-backed analysis tool.
The graph is there when you need it. The views are there so you do not need it all the time.
And that distinction turned out to matter far more than I expected.
A quick detour: auth to Graph without turning OID-See into a SaaS
I want this to be easy to run in a lab tenant, a corp tenant, or a slightly cursed tenant where you do not control everything.
So I made a deliberate choice early on: use device code flow for the scanner.
That gives you:
- No embedded client secrets
- No browser automation
- A predictable experience in terminals and CI style environments
The catch is that device code flow is for user auth, not tied to your own app registration by default. In my first script draft I accidentally conflated those ideas, which led to confusion about which client ID should be used.
For now, the scanner uses the same well-known client ID as Azure CLI:
04b07795-8ddb-461a-bbee-02f9e1bf7b46
That keeps the setup friction low for beta testers. I also included an optional “bring your own app registration” mode so orgs can lock it down further and satisfy change control.
If you are reviewing this as a security engineer, the questions you should ask are:
- What Graph scopes does the scanner need?
- Can I scope it to read-only, and is anything in beta required?
- Can I run it under a dedicated account with least privilege?
The answers, at the moment, are:
- The Azure CLI first party app has some pretty powerful pre-consented permissions/scopes and is prime example of why you should require assignment for most apps rather than let any user in the tenant use them - I created a user in my tenant with no groups, no roles, no permissions and they could authenticate to Azure CLI without issue. Fine for your cloud engineers and software engineers but not for everyone in the company… This is not a criticism of Azure CLI itself, but a reminder that first-party does not mean universally appropriate.
- Beta endpoints are used because some of the relationships are painful or incomplete in v1.0.
- You can absolutely run it under a dedicated application with reduced scopes, and you probably should.
- All you should need are:
Directory.Read.AllApplication.Read.AllAppRoleAssignment.Read.AllDelegatedPermissionGrant.Read.All
What I mean by “Graph-only first”
When I say Graph-only, I mean:
- The scanner can produce a useful export using only Graph responses.
- Any enrichment that touches the public internet is lightweight and/or optional.
- Anything that requires login endpoints, ESTS config blobs, or token scraping is out of scope for the scanner.
This matters because it sets a clear boundary:
- OID-See is for your tenant inventory and relationship mapping.
- It is not a browser plugin.
- It is not a man-in-the-middle.
- It is not a token exfil gadget.
- It is not trying to become an EDR or a SWG.
The more you keep that boundary, the more likely it is to be accepted inside real orgs.
Secrets, credentials, and the “hidden SP secrets” worry
One of the recurring concerns in OAuth app sprawl is: “do we have any long lived credentials sitting around that nobody remembers?”
Graph gives you a partial answer.
For service principals (yes, really - not possible to add or view in the admin portals; can you say shadow persistence) and applications, you can inspect:
passwordCredentialskeyCredentialsfederatedIdentityCredentials(where available)
And you can infer useful things:
- how many credentials exist
- whether any are expired but still present
- whether expiry dates look suspiciously long-lived
- whether you have key rollover issues
This is not perfect, because not all secrets are visible to all identities, and some secret management behaviours are deliberately opaque.
But even a partial view is valuable when you put it into a graph:
- “This app has delegated scopes on Graph”
- “This app also has an active secret that expires in 18 months”
- “This app has no owners”
- “This app does not require assignment”
That combination is the sort of thing you want to review, even if each individual attribute seems benign.
If you are thinking “this feels like cloud identity hygiene”, yes. That is part of the value.
Why I care about public clients
Another one that falls into the “not always visible, but when it is, it matters” bucket is public client configuration.
Public clients are not inherently evil. Native apps exist. Device code exists. Brokered auth exists.
But a surprising number of incidents start with an assumption like:
Nobody could use that app unless they have a secret.
If an app is configured such that it can be used as a public client in places it should not be, you have expanded the attack surface.
So the scanner should capture publicClient settings where Graph provides them, and scoring should treat risky configurations as a separate category.
It is not a guaranteed exploit. It is a “this deserves human eyes” signal.
First party app IDs that are mostly zeros
At some point everyone stumbles over an appId like:
00000001-0000-0000-c000-000000000000
Your instincts kick in and you think: first party.
Often you are right. Sometimes you are only half right.
OID-See should not blindly trust “looks first party”. It should:
- tag “first party shaped” appIds as a hint
- then validate with other signals like verified publisher, known resource SPNs, and domain provenance
- and avoid generating panic when the app is a well-known Microsoft component
This is why the scoring logic cannot rely on one field. It has to be a blend.
Practical query ideas for the viewer
The viewer becomes genuinely useful when you stop panning around randomly and start asking questions.
Some example presets I have been building and using:
- Show me apps with privileged delegated scopes on Microsoft Graph
- Show me apps with application permissions on Graph or SharePoint
- Show me apps with
offline_accessplus no owners - Show me apps that do not require assignment and have no assignments
- Show me apps with wildcard reply URLs
- Show me apps with mixed reply URL registrants
That last one is the big one. If the registrant outliers show a completely different organisation from the rest of the reply URLs, that is where you should lean in.
It is also a nice way to avoid “domain allowlist bikeshedding”, because the data tells you what is weird in context, rather than trying to encode the entire internet into rules.
Scanner design: Graph calls, then enrichment, then scoring
OID-See’s scanner has three jobs:
- Collect the raw relationships from Microsoft Graph
- Enrich and normalise that data into something a viewer can consume
- Produce risk scoring that is explainable and externally configurable
Graph calls: what the scanner needs
At a high level, the Graph calls look like this:
/beta/servicePrincipalsto enumerate service principals and their key properties/beta/applicationsas best-effort to get application objects for in-tenant apps/beta/oauth2PermissionGrantsfor delegated grants/beta/servicePrincipals/{id}/appRoleAssignmentsand related endpoints for app role grants/beta/servicePrincipals/{id}/ownersfor ownership/beta/servicePrincipals/{id}/appRoleAssignedToand assignments to users/groups/beta/directoryRolesand role assignments for directory role context/beta/directoryObjects/getByIdsto resolve principals in bulk, because Graph does not always give you what you need inline
The scanner has to juggle paging, retries, rate limits, and “Graph sometimes returns weird shapes” realities.
You also discover fun facts like:
- Some properties appear on application objects but not on service principals.
- Some service principals have conflicting fields like
signInAudiencevs anisMultiTenantflag. - Some first party service principals have huge reply URL lists, hundreds of entries, which makes analysis expensive.
So performance matters. I added parallelism and got scan time in my tenant down from 8 to 9 minutes to just over 2 minutes. That was a satisfying moment.
Enrichment: reply URL analysis, broker schemes, and domain provenance
Reply URLs are where a bunch of trust signals live, but naive analysis produces false positives.
Examples of why:
- Microsoft owned apps legitimately use multiple domains:
office.net,officeppe.net,microsoftonline.com,office.com. azure.netredirects toazure.microsoft.com.- Broker schemes like
brk-*://are not “non-https redirect URIs” in the usual sense, but they will look like it if you treat everything as a URL.
So the scanner does:
- parse schemes
- normalise domains
- track non-https, localhost, ip literal, punycode, wildcard patterns
- detect brokered schemes by pattern
brk-*:// - identify mixed domain sets
And then optionally enrich with:
- RDAP registrant name
- WHOIS
- DNS nameservers
- ASN info
The key principle is: use enrichment to reduce false positives, not to add more noise.
The best suggestion I got here was to prioritise RDAP lookups and flag outliers by registrant, rather than trying to maintain a static allowlist of “Microsoft domains”.
Scoring: externalised, explainable, and honest
Scoring is where you can accidentally do harm.
If your scoring says “Microsoft Office 365 Portal is critical because it has app roles”, you will lose trust immediately. Because obviously it does. And obviously that is not the point.
So I externalised scoring logic into a JSON file so it can be tuned and reused across scanner and viewer.
Then I iterated on what the codes actually mean.
Two key fixes I landed on:
offline_accessis persistence, not impersonation.- App roles and assignments are not inherently risky without context. Gate them behind privilege and reachability.
This led to a more nuanced model:
HAS_PRIVILEGED_SCOPESmatters.HAS_TOO_MANY_SCOPESmatters.HAS_OFFLINE_ACCESSmatters but as persistence.HAS_APP_ROLEmatters when the app roles are privileged, or when the target resource is juicy, or when assignments and reachability make it exploitable.ASSIGNED_TOmatters mostly when assignment is required, or when you have broad reachability without assignment.
And I reframed “no owners” as a governance gap, not a pure security risk.
One additional scoring signal that turned out to be worth calling out is creation time.
I originally had a placeholder rule named LEGACY. That was vague and slightly judgemental. A better name is something like:
CREATED_PRE_CONSENT_HARDENING
The point is not “old equals bad”. The point is that in July 2025 Microsoft introduced meaningful admin consent hardening for unverified publishers, so apps created before that date may have slipped in under a more permissive consent era. It is a context flag. Not a conviction. And the audit logs that might give more context are almost certainly long gone!
The bug bears: real examples from exports
This section is basically me arguing with my own data.
1) ASSIGNED_TO when assignment is not required
If appRoleAssignmentRequired is false, the “assigned to 1 user” detail is not that meaningful.
It can still be a useful breadcrumb, but it should not inflate risk in the same way it does for apps that require assignment.
So:
- If assignment is required, then lots of assignments increases reach and therefore risk.
- If assignment is not required, then having no assignments is actually a stronger indicator of broad exposure.
That is why I changed scoring to treat “no assignments and assignment not required” as high reachability risk.
2) DECEPTION when publisher is empty or Microsoft-shaped
The deception logic started as: “unverified publisher and publisher name mismatch with display name”.
That is naive.
If publisher is missing, it will always mismatch.
If publisherName is “Microsoft Accounts” but the app is not verified, mismatch tells you nothing.
The improved logic gates deception checks:
- Only run mismatch checks when there is a meaningful publisher signal to compare.
- Add a separate “identity laundering suspected” heuristic when publisher surfaces are Microsoft-shaped but the app is unverified and the domains do not align.
In other words: deception is a specific claim. Do not make it unless you have evidence.
3) False positive identity laundering for Microsoft domains
If you simply flag “mixed reply URL domains”, you will flag half of Microsoft’s own ecosystem.
So you need a second stage:
- Are these domains Microsoft-owned?
- Do they share registrant, nameserver patterns, and ASN context?
- Are they part of the same expected ecosystem?
That is why RDAP and DNS enrichment matters.
4) publicClient not null
This is one of those fields where you only notice it when it bites you.
If an application is configured as a public client in ways that should not be allowed, that is a real risk signal.
If publicClient is not null, capture it and score it.
And yes, make sure this is coming from the right object type, because Graph loves to be inconsistent.
5) Wildcard reply URLs like https://*.cpim.windows.net/*
If you want to set my threat modeller brain on fire, show me a wildcard redirect URI.
Now, for CPIM service, there are legitimate reasons for a wildcard pattern in internal Microsoft services.
But it is still worth flagging as a category because:
- Wildcards expand the redirect surface.
- Redirect surfaces intersect with subdomain takeover classes of bugs.
- Even if the service is Microsoft, this is a pattern defenders should recognise and watch.
OID-See should flag it as “reply URL wildcard present” rather than shouting “subdomain takeover right now”.
That is the balance.
6) isMultiTenant false, signInAudience AzureADMultipleOrgs
This one is just funny.
I have seen isMultiTenant appear on application objects, not service principals, and it can conflict with signInAudience.
So the scanner should:
- record both when available
- prefer
signInAudiencefor behavioural interpretation - treat conflicts as a “data quality” note, not a risk reason
Because the risk is not “Microsoft Graph is confusing”, even though it is.
7) HAS_SCOPES edge clutter
The viewer does not need every resolved scope display name when the display name is the same as the value.
So resolvedScopes became clutter and was removed. If you want to enrich later, do it in the UI with a lookup table, not in every edge.
That reduced JSON size and made the viewer cleaner.
The “BloodHound for OAuth” part: derived edges and paths
The most important part of a graph tool is not the nodes, it is the ability to find paths.
OID-See already draws the “facts”. The next leap is derived paths.
Two derived concepts matter most:
- Effective impersonation paths: what a compromised app can do as a user, across resources, given its delegated grants and reachability
- Persistence paths: what a compromised app can maintain long term access via, like refresh tokens and long lived credentials
This is why I added edge types like:
EFFECTIVE_IMPERSONATION_PATHPERSISTENCE_PATH
Even if you do not compute deep multi-hop paths on day one, having the data model for it is important.
This is the bridge between “inventory tool” and “abuse path tool”.
Practical guidance: how to use OID-See in anger
This is the workflow I have been using:
- Run the scanner in Graph-only mode first.
- Load the JSON into the viewer.
- Switch to a “risk” lens and filter to high and critical.
- For each suspicious app:
- Check verified publisher
- Check reply URL domains
- Check privileged delegated scopes and app roles
- Check reachability: assignment required vs not required
- Check owners and governance
- Then rerun with enrichment enabled if you need to validate domain provenance.
- Export a shortlist of apps to review with your identity team.
This is not a replacement for CSPM or ISPM. It is complementary.
CSPM tends to ask: “is this app multi-tenant?”
OID-See asks: “can this app impersonate a user, and how far does that blast radius go?”
Where this sits: ISPM, but more like identity attack surface management
I have asked myself whether this is ISPM.
Kind of, but most ISPM tools I have seen focus on policies, controls, and posture. They tend to be control plane focused.
OID-See is a bit different because it is graph-based and relationship-first. It is closer to identity attack surface management for OAuth and workload identity.
It is the difference between:
- “Do we have a policy?”
- “What can this thing actually do?”
I suspect we will see more tools move in this direction, because attackers already are.
The bikeshedding I embraced (and the bikeshedding I avoided)
At some point in every tool build you have a choice:
- Build the thing that matters.
- Or spend three hours arguing with CSS about whether a button is 2 pixels too tall.
I did both. I am not proud.

The viewer needed to be attractive because humans are visual creatures. If the UI looks like a spreadsheet, people will treat it like a spreadsheet, and then they will stop using it.
But I also had to stop myself from doing UI work as a form of procrastination.
My personal rule became:
- If the scanner output is wrong, fix the scanner.
- If the scanner output is right, then and only then polish the viewer.
The “focus” feature is a good example. It is valuable, but it is not core. It is a quality of life feature that makes large graphs bearable. I will come back to it, but I refused to block the scanner on it.
The scanner is the product. The viewer is the proof that the scanner is useful.
What surprised me most
A few things genuinely surprised me during this journey:
-
How much of the “truth” is spread across different Graph endpoints.
You cannot just pull service principals and call it done. You need grants, assignments, owners, role context, and resource mapping. -
How many false positives you can create with naive URL analysis.
It is embarrassingly easy to flag Microsoft for being Microsoft. The only way out is provenance checks, not vibes. -
How quickly people get value once the relationships are visible.
The moment you show an identity team that an app has privileged scopes, no owners, and broad reachability, the conversation changes. It stops being “is this fine?” and becomes “who approved this and why?”. -
How much I enjoyed building something that is not a SaaS.
There is something deeply satisfying about shipping a tool that runs locally, produces a file, and lets you explore it without sending telemetry to the void.
If you are a defender, you already know this feeling: we have enough systems that want data. Sometimes you just want a tool that gives you clarity.
Final TL;DR
OID-See exists because OAuth risk in Entra is a relationship problem.
- Tables make you miss abuse paths.
- Graphs make abuse paths obvious.
- Microsoft metadata sometimes lies or at least misleads, especially around publisher and ownership.
- Verified publisher is valuable, but unverified apps can still look Microsoft-shaped.
- Reply URLs and domain provenance matter a lot, but naive checks create false positives.
- Scoring needs to be explainable, tunable, and careful about what it claims.
Also, you should absolutely be giving your OAuth apps the side-eye.
If you want a GIF to set the tone for your first scan report, I recommend one of:
- The “community” meme: “Oh no, that is not good.”
- The classic: a goose staring suspiciously at the camera (geese are the real threat)
Now go play:
- https://oidsee.app/ (viewer UI hosted by me - you can also build and run in npm locally, instructions in the repo)
- https://github.com/OID-See/OID-See/tree/v1.0.0 (Repo with all docs and source code for viewer and scanner
Appendix: terminology quickies
- Service principal: the in-tenant representation of an application, including assignments and grants.
- Delegated scopes: permissions an app uses on behalf of a signed-in user.
- Application permissions (app roles): permissions an app has as itself, often with larger blast radius.
- Reachability: how many principals can use the app, and whether assignment gating exists.
- Identity laundering: Microsoft-shaped metadata that can mislead trust decisions for unverified apps.