
A Valid OAuth State Does Not Prove You Own the GitHub Installation
A newly disclosed identrail flaw shows how a correctly scoped connection state can still be paired with an attacker-supplied GitHub App installation ID. The result is a cross-tenant path to another customer's private repository inventory.
Published yesterday, the identrail advisory GHSA-cp3j-m783-3ph5 is a clean example of a security mistake I keep seeing in integration-heavy products: the application correctly verifies that a user is allowed to finish a workflow, then forgets to verify that the external resource supplied during that workflow belongs to them.
The vulnerable value here is a GitHub App installation_id.
An authenticated identrail tenant can start a GitHub connection flow for their own workspace, obtain a state value tied to that workspace, and then complete the connection using a GitHub App installation ID belonging to another identrail customer. identrail's own GitHub App credentials can then mint an installation access token for that supplied installation and list the victim organization's private repositories.
That is not an authentication bug in the usual sense. The attacker is authenticated. Their state token is valid. Their workspace access is valid.
The missing property is ownership binding.
The dangerous distinction: workflow authorization versus resource authorization
The affected completion endpoint is:
POST /v1/workspaces/:workspace_id/projects/:project_id/github/connect/completeThe advisory identifies the default-on implementation in internal/api/router.go and internal/api/github_connect.go, specifically CompleteGitHubConnection.
At a glance, the flow has a security control that looks reassuring. The connection state is bound to the caller's tenant, workspace, and project. On completion, identrail verifies that the saved state record still matches the caller's scope.
Conceptually, it is doing this:
// Illustrative pseudocode
if state.TenantID != caller.TenantID ||
state.WorkspaceID != project.WorkspaceID ||
state.ProjectID != project.ProjectID {
return forbidden
}That is good. State should be scoped. It stops one tenant from taking a state value created for another tenant and using it to complete a connection in their own workspace.
But the connection endpoint also accepts an installation_id, either from the JSON request body or from the X-GitHub-Installation-ID header. That ID is controlled by the client. According to the advisory, the meaningful validation is only that the number is positive before it is persisted as the workspace's GitHub connection.
So the effective authorization logic becomes:
// Illustrative pseudocode
assertStateBelongsTo(caller, workspace, project)
assert(request.InstallationID > 0)
saveConnection(workspace, request.InstallationID)That second assertion is not authorization. It is input validation.
A positive integer can still identify somebody else's installation.
A state token proves who may continue a flow. It does not prove that every identifier attached to that flow is theirs to use.
This is the core failure. The product binds the ceremony to the tenant but does not bind the asset being connected.
Why this becomes a cross-tenant repository read primitive
A GitHub App installation ID is not a secret. The advisory notes that installation IDs can appear in post-install redirect URLs, webhook payloads, an organization's GitHub App settings, and as enumerable integers.
That matters because the attack does not require stealing a victim's OAuth token, compromising their GitHub account, or guessing a cryptographic secret. The attacker needs a valid installation ID for an organization where identrail's GitHub App is installed.
The attack path described in the advisory is straightforward:
- An attacker signs in as any identrail tenant.
- They call
StartGitHubConnectionfor a workspace and project they control, receiving a state value scoped to their own environment. - They submit that valid state to the completion endpoint, but pair it with a victim organization's GitHub App
installation_id. - The state check passes because it belongs to the attacker. The installation ID passes because it is a positive integer.
- identrail persists the victim installation under the attacker's workspace.
- identrail's GitHub connector mints an installation access token using its own App JWT and calls GitHub for that installation.
- The attacker can see the victim organization's private repository inventory and drive posture scans or repository reads through their own workspace.
The critical implementation detail is that identrail is not merely storing an arbitrary foreign ID and hoping for the best later. The connector's ListInstallationRepositories flow mints a token through GitHub's installation token endpoint, POST /app/installations/{installationId}/access_tokens, using the App JWT.
For any installation where the identrail app is installed, GitHub can issue the app an installation token. That is expected GitHub App behavior.
The application is supposed to decide whether the current identrail workspace is permitted to invoke that power for a specific installation. In this case, it apparently does not.
GitHub is enforcing the App's authority. identrail needed to enforce the tenant boundary above it.
This is an IDOR, but the ID is only half the story
Calling this an IDOR is correct, but it can undersell why it is serious.
Traditional insecure direct object references often look like changing /users/123 to /users/124. Here, the vulnerable object is an external authorization context: a GitHub App installation. The application accepts its identifier from the browser, links it to the wrong tenant, and later uses privileged server-side credentials to act on it.
That pattern shows up everywhere integration platforms exist:
- cloud account IDs attached to onboarding requests
- payment account identifiers submitted after a redirect
- messaging workspace IDs passed to callback handlers
- source-control organization IDs selected in a client UI
- webhook endpoint IDs linked to a team or project
The identifier may be public. It may be guessable. It may even be intentionally visible in a URL.
None of that is inherently bad.
The bad part is treating visibility of an identifier as proof that the caller may bind it to their account. Public identifiers still point at private resources.
The code had the right idea — applied inconsistently
The most instructive part of this advisory is the asymmetry.
The state record is tied to {TenantID, WorkspaceID, ProjectID} and checked again during completion. That tells us the design already recognizes that a connection flow must be tied to the initiating scope.
Then the installation ID, which is arguably the more sensitive input, arrives directly from the client and is not tied to that same scope.
This is where teams get trapped by happy-path thinking. A GitHub installation flow probably works as intended in normal testing:
- user starts the connection
- user installs or selects the app in GitHub
- GitHub redirects back
- frontend sends the installation ID
- connection succeeds
Everything looks coherent because the UI gives the backend an ID that came from the expected screen.
Attackers do not use your UI as an authorization boundary. They replay the final request with a different value.
If a parameter can be supplied by the client, assume it can be replaced, omitted, duplicated, moved into a header, or taken from a previous session. Then ask the only question that matters: what server-side fact proves this caller can use this exact resource?
If the answer is "the frontend only sends IDs the user selected," there is no authorization control.
The V2 route should not be treated as a separate problem
The advisory also calls out a feature-flagged V2 path, CompleteGitHubConnector, which was default-off. It shares the installation ownership gap and is described as weaker because it matches a pending connector using the state value alone, without the caller-scope re-check present in the default path.
That is a useful warning for remediation work.
Do not patch the one handler named in the report and declare victory. Map every path that can:
- create a pending integration connection
- accept or update an installation ID
- exchange app credentials for an installation token
- enumerate repositories or otherwise act through that token
Integration code often has legacy routes, versioned flows, migration paths, API endpoints for a UI, and background worker paths. The same authorization decision can be duplicated across all of them, usually with subtle differences.
A single, centralized ownership assertion is safer than several handlers each attempting their own interpretation of "does this installation belong here?"
What a robust fix needs to establish
The advisory's remediation direction is exactly right: bind the installation_id as rigorously as the state, and/or verify after token minting that the installation account corresponds to an organization the initiating workspace is authorized to connect.
In practice, I would want a design with two layers.
First, the server should create and retain a pending connection record scoped to the tenant, workspace, and project. The installation identity should come from GitHub's signed post-install redirect or callback path, not from a freely chosen browser value. That installation identity must be pinned to that pending record before a workspace connection is finalized.
Second, before persisting the connection or exposing repository data, the service should verify that the installation's account maps to the GitHub organization or account the workspace is allowed to authorize. This turns the final action into an explicit server-side authorization decision instead of an assumption based on how the flow started.
The conceptual invariant should be simple:
caller may connect installation I
only if pending state S belongs to caller's scope
and installation I is the installation bound to S
and I belongs to an account authorized for that scopeThat may feel redundant. Good. Tenant isolation deserves redundancy when a server-held credential can convert an ID into access to private repositories.
There is also an operational question after fixing the endpoint: audit existing connections. If the vulnerable path persisted arbitrary installation IDs under attacker-controlled workspaces, a code fix alone does not tell you whether suspicious cross-tenant bindings already exist. Review historical connection records, completion events, and repository enumeration activity for installations associated with unexpected tenants or workspaces. The source material does not establish whether exploitation occurred, so this should be handled as an investigation, not stated as a fact.
What maintainers of integration products should test this week
This bug class is easy to miss in unit tests because normal tests exercise a valid state paired with the expected resource. Add negative tests that deliberately split them apart.
For every connect or callback endpoint, test these cases:
- a valid state from tenant A paired with a resource ID owned by tenant B
- a valid state from workspace A paired with a resource authorized for workspace B in the same tenant
- a state created by one user but completed by another user with different scope
- direct requests with values changed after the UI flow completes
- duplicate resource IDs sent in the body, headers, or alternative request formats
- old pending states paired with newly observed resource identifiers
Also inspect the first code path that turns an external ID into a privileged credential. In this case, that is the point where identrail uses its App JWT to mint an installation token. That boundary is where tenant ownership should be undeniable.
The lesson from GHSA-cp3j-m783-3ph5 is not "validate GitHub installation IDs more carefully." It is broader and more useful: whenever your backend holds a platform credential with access across many customers, every client-supplied external identifier is a tenant-bound authorization decision.
Treat it that way before your own integration flow becomes a cross-tenant token vending machine.
The advisory references identrail/identrail@835e405 and the v1.0.2 release. If you run identrail or maintain a fork, review the advisory and the relevant connection paths directly rather than assuming a route-level patch covers every integration lifecycle.
Related posts
- Security
How I got free cinema credit by ordering -2 popcorns
A missing input validation on M-Tix Cinema XXI's food ordering API let me increase my account balance by submitting negative quantities. No tools needed — just a browser.
May 19, 2026 · 6 min - Security
How I analyze API security headers in 30 seconds
A quick checklist for reading HTTP response headers and spotting security misconfigurations before you even look at the response body.
May 18, 2026 · 7 min - Security
Common auth mistakes I find when reverse-engineering APIs
After years of poking at APIs that weren't meant to be poked at, these are the auth patterns that break most often — and why.
May 18, 2026 · 9 min