Actions · Azure · Azure-Pipelines · AzureDevOps · GitHub · GitHub-Actions · Service-Connection · Workflow

GitHub ↔ Azure DevOps Integration: A Deep Dive

Scope: Repositories live in GitHub (Enterprise); Boards and Pipelines live in Azure DevOps.
This document explains every moving part—identities, tokens, webhooks, and apps—that make the integration work.

Often times, I see a lot of people struggle to comprehend the GitHub and Azure DevOps integrations- how the identities are involved, how tokens are managed and what is the best way to integrate them etc. Which led me writing this article to shed some light into the under-the-hood matter.

Table of Contents

  1. Architecture Overview
  2. Service Connections — What Happens Under the Hood
  3. Deep Dive: GitHub App Authentication — The Full Machinery
  4. The “Azure Pipelines” GitHub App — Role & Internals
  5. Trigger Mechanism — How a Commit Becomes a Pipeline Run
  6. Azure Boards AB# Linking — Multi-Org Resolution
  7. References

1. Architecture Overview

When you use GitHub for source code and Azure DevOps for boards + pipelines, three integration surfaces exist:

SurfaceGitHub SideAzure DevOps SideCommunication Channel
Pipeline ↔ RepoGitHub App / OAuth App / WebhookService Connection (stores credentials)Webhooks (GitHub → AzDO) + REST API (AzDO → GitHub)
Board ↔ RepoAzure Boards GitHub AppBoard project connectionWebhooks (GitHub → AzDO)
Status ChecksChecks API / Commit Status APIPipeline agent reports backREST API (AzDO → GitHub)

There are two distinct GitHub Apps involved (often confused):

  • Azure Pipelines (github.com/apps/azure-pipelines) — for CI/CD pipeline integration.
  • Azure Boards (github.com/apps/azure-boards) — for work-item linking via AB#.

Each is installed separately and serves a different purpose.

Citation: GitHub integration overview — Azure DevOps | Microsoft Learn


2. Service Connections — What Happens Under the Hood

When you create a pipeline in Azure DevOps and select a GitHub repo, Azure DevOps must create a service connection — a secure credential store that lets the pipeline reach GitHub. There are three authentication methods, each with very different internals.

2.1 OAuth-Based Service Connection

This is the flow that happens when you click “Grant authorization” in the Azure DevOps new-pipeline wizard.

Prerequisite: Microsoft Pre-Registered an OAuth App on GitHub

This is the key detail that makes the whole flow work.

Before any user ever clicks “Grant authorization,” Microsoft’s engineering team registered an OAuth App on GitHub through the standard developer registration process (the same github.com/settings/developers → “New OAuth App” flow any developer can use). When you register an OAuth App on GitHub, GitHub issues a client_id and client_secret pair. Microsoft stores that client_secret on the Azure DevOps backend.

So GitHub’s authorization server already knows this client_id — because GitHub itself created and issued it. The OAuth App shows up as “Azure Pipelines” when you look at your authorized applications in GitHub (github.com/settings/applications). There is a separate OAuth App called “Azure Boards” for the boards integration — they are distinct apps with different client IDs.

This is standard OAuth 2.0 — the authorization server (GitHub) must have a prior registration of the client (Azure DevOps) before it will accept any authorization requests. Without this pre-registration, GitHub would reject the /login/oauth/authorize redirect with an “application not found” error.

DetailValue
OAuth App NameAzure Pipelines (not “Azure DevOps” — this is the actual name as registered on GitHub)
Registered ByMicrosoft (centrally managed)
Registered OnGitHub’s OAuth App registry
client_id0d4949be3b947c3ce4a5 — issued by GitHub, hardcoded in Azure DevOps backend
client_secretIssued by GitHub, stored encrypted on Azure DevOps servers (never exposed to users)
Callback URLPoints back to Azure DevOps (e.g., https://dev.azure.com:443/{org}/{projectId}/_admin/_services/callback/github)
Visible To UsersYes — appears in github.com/settings/applications → “Authorized OAuth Apps” as “Azure Pipelines” after consent
Management URLgithub.com/settings/connections/applications/0d4949be3b947c3ce4a5

Citation proving client ID ownership: The official Microsoft documentation at MicrosoftDocs/azure-devops-docs/docs/pipelines/repos/github.md explicitly references this client ID four times, each time directing users to manage the Azure Pipelines OAuth App permissions at https://github.com/settings/connections/applications/0d4949be3b947c3ce4a5. The rendered version is at Build GitHub repositories — Azure Pipelines | Microsoft Learn.

Note: There is a separate OAuth App called “Azure Boards” also registered by Microsoft on GitHub — that one handles the boards/work-item integration. They are two distinct OAuth Apps with different client IDs.

Real-World Proof: Captured OAuth Redirect

When you create a pipeline in Azure DevOps and choose “Grant authorization,” your browser is redirected to a URL like this (captured from a live session):

https://github.com/login/oauth/authorize
  ?client_id=0d4949be3b947c3ce4a5
  &scope=repo,read:user,user:email,admin:repo_hook
  &state=<double-URL-encoded JSON>

The state parameter, when double-URL-decoded, reveals a JSON object that Azure DevOps uses to route the callback:

{
  "ConfigurationId": "9b079bb9-758f-452e-a3e8-8deed3054af2",
  "ProjectId": "489c881e-0e9a-4017-b02c-e3378584f955",
  "CallbackUri": "https://dev.azure.com:443/moim/{projectId}/_admin/_services/callback/github?redirectUrl=...&useWellKnownStrongBoxLocation=true&__RequestVerificationToken=...",
  "AuthorizationUrl": "https://github.com//login/oauth/authorize?client_id=0d4949be3b947c3ce4a5",
  "EndpointType": "github"
}
State FieldPurpose
ConfigurationIdUnique ID for the service connection being created
ProjectIdThe Azure DevOps project GUID
CallbackUriWhere GitHub sends the authorization code back to (AzDO’s callback endpoint)
redirectUrl (nested)Where AzDO sends your browser after storing the token (back to the pipeline wizard UI)
__RequestVerificationTokenCSRF protection — AzDO validates this when the callback arrives
EndpointTypeTells AzDO’s callback handler this is a GitHub connection (vs. Bitbucket, GitLab, etc.)

The four OAuth scopes requested tell you exactly what Azure DevOps needs:

ScopeWhat It Grants
repoFull read/write access to all repos the user can access (clone source, read YAML)
read:userRead the user’s GitHub profile information
user:emailRead the user’s email addresses (used to match identity across systems)
admin:repo_hookCreate and delete webhooks on repositories (needed for pipeline triggers)

Citation: Authorizing OAuth Apps — GitHub Docs

Step-by-step internal flow:

  1. Redirect to GitHub: Azure DevOps redirects your browser to github.com/login/oauth/authorize with the pre-registered client_id that GitHub itself issued to Microsoft. GitHub looks up this client_id in its OAuth App registry, finds the “Azure DevOps” application, and renders the consent screen showing the app name, logo, and requested permissions.
  2. User consent: GitHub shows a consent screen. The scopes requested are:
    • repo — full access to private and public repositories (needed to clone source code).
    • admin:repo_hook — ability to create/delete webhooks on repos (needed for triggers).
    • repo:status — read/write commit status (needed to report build results).
  3. Authorization code returned: GitHub redirects back to Azure DevOps with a one-time authorization code.
  4. Token exchange: Azure DevOps server-side exchanges the authorization code for an OAuth access token (and optionally a refresh token) via POST https://github.com/login/oauth/access_token.
  5. Storage: The access token is encrypted at rest and stored in the Azure DevOps service connection. The token is scoped to the user who authorized it — it can access only the repos that user can access.

What the service connection object holds:

FieldValue
Connection TypeGitHub
Auth SchemeOAuth
OAuth Access TokenEncrypted, stored in Azure DevOps credential store
Refresh TokenEncrypted (if provided by GitHub; GitHub classic OAuth does not always issue refresh tokens)
Token OwnerThe GitHub user who performed the consent
Scopesrepoadmin:repo_hookrepo:status

Token lifecycle:

  • GitHub classic OAuth tokens do not expire unless the user revokes them or the org admin deauthorizes the OAuth App.
  • If the token is revoked, pipelines will fail silently the next time they try to use it.
  • Azure DevOps does not currently auto-rotate OAuth tokens.

Citation: Build GitHub repositories — Azure Pipelines | Microsoft Learn


2.2 PAT-Based Service Connection

When you choose “Personal Access Token,” the flow is simpler but carries more operational overhead.

What happens:

  1. You generate a PAT in GitHub → Settings → Developer settings → Personal access tokens.
  2. You paste the PAT into the Azure DevOps service connection form.
  3. Azure DevOps stores it encrypted in its credential vault.

What the service connection holds:

FieldValue
Connection TypeGitHub
Auth SchemeToken
Personal Access TokenThe raw PAT string, encrypted at rest
Token OwnerThe GitHub user who created the PAT
Required Scopesrepo (full repo access), admin:repo_hookread:useruser:email

How it’s used at runtime:

When the pipeline agent needs to clone the repo, Azure DevOps injects the PAT into the git clone command as a bearer token:

git -c http.extraheader="AUTHORIZATION: basic <base64(:PAT)>" clone https://github.com/org/repo.git

The PAT is never logged — Azure DevOps masks it in pipeline output. It’s injected only for the duration of the checkout step.

Key risks:

  • The PAT is tied to a single human user. If that user leaves the org, pipelines break.
  • PATs have expiration dates. You must manually rotate them.
  • A PAT with repo scope grants access to every repo the user can see, not just the one in the pipeline.

Citation: Managing your personal access tokens — GitHub Docs


2.3 GitHub App-Based Service Connection (Recommended)

This is the most secure option and the one Microsoft recommends for production.

How it differs:

Instead of authenticating as a user, Azure DevOps authenticates as the Azure Pipelines GitHub App — an application identity with its own permissions, independent of any human account.

But first: How does Azure DevOps get the private key?

This is a crucial detail. The private key is NOT shared between GitHub and Azure DevOps. It uses asymmetric cryptography (RSA), where the private key and public key are a mathematically linked pair but knowing one doesn’t reveal the other.

Here’s how it works for GitHub Apps in general:

Key fact: After you download the private key .pem file, GitHub does not retain a copy of it. If you lose it, you must generate a new one. GitHub only keeps the public key to verify signatures.

Citation: Managing private keys for GitHub Apps — GitHub Docs — “Private keys are the single most valuable secret for a GitHub App. Store them securely. Private keys do not expire but can be deleted.”

Now, who owns the Azure Pipelines GitHub App?

The Azure Pipelines GitHub App (github.com/apps/azure-pipelines) is a first-party app registered and owned by Microsoft. This means:

  • Microsoft’s engineers registered the app on GitHub.
  • Microsoft’s engineers clicked “Generate private key” and downloaded the .pem file.
  • Microsoft stores that private key on the Azure DevOps backend infrastructure (likely in Azure Key Vault or a comparable secrets management system).
  • GitHub only has the corresponding public key.
  • You (the end user) never see or touch this private key.

This is fundamentally different from a scenario where you create your own custom GitHub App for a service connection — in that case, you would generate the private key and upload it to Azure DevOps yourself.

The two scenarios compared:

ScenarioWho owns the App?Who generated the private key?Where is it stored?
Built-in Azure Pipelines AppMicrosoftMicrosoftAzure DevOps backend (Microsoft-managed)
Custom GitHub App service connectionYouYou (downloaded .pem from GitHub)You upload it to Azure DevOps service connection

Internal token flow (asymmetric — no shared secrets):

  1. JWT generation: Azure DevOps generates a short-lived JWT (JSON Web Token) signed with the App’s private key using the RS256 algorithm (RSA + SHA-256). The JWT contains:
    • iss (issuer): The App ID of the Azure Pipelines GitHub App.
    • iat (issued at): Current time − 60 seconds (to handle clock drift).
    • exp (expires at): Current time + 10 minutes (maximum allowed by GitHub).
  2. JWT verification: GitHub receives the JWT and verifies the signature using the public key it retained when the private key was generated. This proves the request genuinely came from the app owner (Microsoft), without the private key ever crossing the wire.
  3. Installation access token exchange: If the JWT is valid, Azure DevOps calls:POST https://api.github.com/app/installations/{installation_id}/access_tokens Authorization: Bearer <JWT> GitHub returns a short-lived installation access token (valid for 1 hour) scoped to only the repos the App is installed on.
  4. Usage: This installation token is used to clone code, post statuses, etc. After 1 hour it expires, and a new one is generated by repeating steps 1–3.

Why this matters: The private key never leaves Azure DevOps servers. It’s never sent to GitHub. Only the mathematical proof (the signed JWT) crosses the wire, and even that expires in 10 minutes. GitHub verifies it with the public key. This is the same asymmetric-key pattern used in TLS/HTTPS, SSH, and code signing.

What the service connection holds:

For the built-in Azure Pipelines App, the service connection is lightweight because Microsoft manages the credentials internally:

FieldValue
Connection TypeGitHub
Auth SchemeGitHubApp
App IDThe numeric ID of the Azure Pipelines GitHub App (managed by Microsoft)
Installation IDThe ID of this specific installation in your GitHub org
Private KeyNot stored in your service connection — it’s on Microsoft’s backend

For a custom GitHub App service connection you create yourself:

FieldValue
Connection TypeGitHub
Auth SchemeGitHubApp
App IDYour custom App’s numeric ID
Installation IDYour installation ID
Private Key (PEM)The .pem file you downloaded from GitHub and uploaded to Azure DevOps, encrypted at rest

Why this is best:

  • No human dependency — the credentials aren’t tied to any user account.
  • Short-lived tokens — installation tokens expire in 1 hour; there’s no long-lived secret to steal.
  • Asymmetric crypto — the private key never crosses the network; only signed JWTs (which expire in 10 min) are transmitted.
  • Fine-grained scope — the App only has access to repos it’s explicitly installed on, not all repos the user can see.
  • Survives employee departures — if the person who set it up leaves, the App keeps working.

Citations:
Managing private keys for GitHub Apps — GitHub Docs — explains private key generation, storage, and rotation.
Generating a JWT for a GitHub App — GitHub Docs — explains the RS256-signed JWT structure.
Generating an installation access token — GitHub Docs — explains the JWT→token exchange.
About authentication with a GitHub App — GitHub Docs — overview of the asymmetric authentication model.


3. Deep Dive: GitHub App Authentication — The Full Machinery

This section explains every byte-level step of how a GitHub App authenticates to the GitHub API. This is the mechanism Azure DevOps uses internally when the Azure Pipelines GitHub App is configured.

3.1 The Three Levels of GitHub App Identity

A GitHub App can authenticate at three different levels, each with different capabilities and token types:

Azure Pipelines uses levels 1 and 2: It authenticates as the app (JWT) to get an installation token, then uses that installation token to interact with your repos.

Citation: About authentication with a GitHub App — GitHub Docs

3.2 GitHub Token Prefixes — How to Identify Any Token

GitHub uses identifiable prefixes on all its tokens so they can be detected by secret scanners:

PrefixToken TypeExample Use
ghp_Personal Access Token (classic)Developer automation
github_pat_Fine-grained Personal Access TokenScoped developer access
gho_OAuth Access TokenOAuth App integrations (like the Azure Pipelines OAuth flow)
ghu_GitHub App User-to-Server TokenApp acting on behalf of a specific user
ghs_GitHub App Installation Token (server-to-server)This is what Azure Pipelines uses
ghr_Refresh TokenToken renewal in OAuth/App flows

So when Azure DevOps internally receives a token starting with ghs_, it knows this is an installation access token scoped to the installed repos.

Citation: Behind GitHub’s new authentication token formats — GitHub Blog

3.3 Step 1: Producing the JWT — Byte by Byte

A JWT consists of three parts separated by dots: HEADER.PAYLOAD.SIGNATURE, each Base64URL-encoded.

The Header

{
"alg": "RS256",
"typ": "JWT"
}
  • algRS256 — RSA signature with SHA-256 hash. This is the only algorithm GitHub accepts.
  • typ: Always JWT.

This is Base64URL-encoded to produce the first segment:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9

The Payload (Claims)

{
"iat": 1710700000,
"exp": 1710700600,
"iss": "12345"
}
ClaimTypeValueRequirement
issStringThe GitHub App’s numeric App ID (e.g., "12345")Required. Found in the App’s settings page on GitHub.
iatIntegerUnix timestamp of when the JWT was issued. GitHub recommends setting this to current time − 60 seconds to account for clock drift between servers.Required.
expIntegerUnix timestamp of when the JWT expires. Maximum 10 minutes after iat. GitHub rejects JWTs with longer expiry.Required.

Important: The iss claim must be the App ID as a string, not an integer. Some JWT libraries serialize integers differently, which can cause signature validation failures.

This is Base64URL-encoded to produce the second segment.

The Signature

The signature is computed as:

RSASSA-PKCS1-v1_5(
SHA-256(
Base64URL(header) + "." + Base64URL(payload)
),
private_key
)

In plain English:

  1. Concatenate the Base64URL-encoded header and payload with a dot separator.
  2. Hash the result with SHA-256.
  3. Sign the hash with the RSA private key using PKCS#1 v1.5 padding.
  4. Base64URL-encode the signature.

The final JWT is: <header>.<payload>.<signature>

Code Example (Python):

import jwt       # PyJWT library
import time
import pathlib

# Load the private key (.pem file downloaded from GitHub)
private_key = pathlib.Path("app-private-key.pem").read_text()

# Build the JWT
now = int(time.time())
payload = {
    "iat": now - 60,          # Issued 60 seconds ago (clock drift buffer)
    "exp": now + (10 * 60),   # Expires in 10 minutes
    "iss": "12345"            # Your GitHub App ID (as string)
}

encoded_jwt = jwt.encode(payload, private_key, algorithm="RS256")
# Result: "eyJhbGci....<long string>....XYZ"

Citation: Generating a JSON Web Token (JWT) for a GitHub App — GitHub Docs

3.4 Step 2: Authenticating as the App (JWT → GitHub API)

With the JWT in hand, Azure DevOps can now authenticate as the app itself. This is the most limited level — it can only call app-level endpoints:

GET https://api.github.com/app
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Accept: application/vnd.github+json

What GitHub does when it receives this request:

  1. Decodes the JWT header → extracts alg: RS256.
  2. Decodes the JWT payload → extracts iss: "12345" (App ID).
  3. Looks up App ID 12345 in its database → finds the corresponding public key.
  4. Verifies the signature: takes Base64URL(header).Base64URL(payload), hashes it, and checks the signature against the public key.
  5. Checks exp → if the JWT has expired, rejects with 401.
  6. Checks iat → if it’s too far in the future, rejects.
  7. If all checks pass → responds with app metadata.

Response (app metadata):

{
  "id": 12345,
  "slug": "azure-pipelines",
  "name": "Azure Pipelines",
  "owner": {
    "login": "Microsoft",
    "type": "Organization"
  },
  "html_url": "https://github.com/apps/azure-pipelines",
  "created_at": "2018-09-01T00:00:00Z",
  "permissions": {
    "checks": "write",
    "contents": "read",
    "pull_requests": "write",
    "statuses": "write",
    "metadata": "read"
  }
}

What can be called with just the JWT (limited set):

EndpointPurpose
GET /appGet the App’s own metadata
GET /app/installationsList all organizations/accounts where the App is installed
GET /app/installations/{id}Get details of a specific installation
POST /app/installations/{id}/access_tokensCreate an installation access token (this is the critical one)
DELETE /app/installations/{id}Delete an installation

You CANNOT clone repos, read code, create issues, or post statuses with just the JWT. You must exchange it for an installation token first.

Citation: Authenticating to the REST API — GitHub Docs

3.5 Step 3: Getting the Installation ID

Before requesting an installation token, Azure DevOps needs to know the installation ID — a numeric identifier representing “this App installed in this specific org/account.”

GET https://api.github.com/app/installations
Authorization: Bearer <JWT>
Accept: application/vnd.github+json

Response (list of installations):

[
  {
    "id": 98765,
    "account": {
      "login": "your-github-org",
      "type": "Organization"
    },
    "app_id": 12345,
    "target_type": "Organization",
    "permissions": {
      "checks": "write",
      "contents": "read",
      "pull_requests": "write"
    },
    "repository_selection": "selected",
    "created_at": "2024-01-15T10:00:00Z"
  }
]

Key fields:

  • id: 98765 — this is the installation ID needed for the next step.
  • repository_selection: "selected" — means the App only has access to specifically chosen repos (not all repos in the org).

In practice, Azure DevOps already stores the installation ID in the service connection from when the App was first installed, so this step is usually skipped at runtime.

Citation: Authenticating as a GitHub App installation — GitHub Docs

3.6 Step 4: Exchanging the JWT for an Installation Access Token

This is the critical step. Azure DevOps trades the short-lived JWT for a scoped installation token:

POST https://api.github.com/app/installations/98765/access_tokens
Authorization: Bearer <JWT>
Accept: application/vnd.github+json
Content-Type: application/json

{
  "repositories": ["my-repo"],
  "permissions": {
    "contents": "read",
    "checks": "write"
  }
}

The request body is optional. If omitted, the token gets all permissions and all repos the installation has access to. If provided, it allows further scoping down — you can request fewer permissions or fewer repos than the App is installed with.

Response:

{
  "token": "ghs_ABCDEFghijklmnop1234567890xyz",
  "expires_at": "2026-03-17T09:53:00Z",
  "permissions": {
    "contents": "read",
    "checks": "write"
  },
  "repository_selection": "selected",
  "repositories": [
    {
      "id": 555,
      "name": "my-repo",
      "full_name": "your-github-org/my-repo",
      "private": true
    }
  ]
}

Critical details about this token:

PropertyValueMeaning
tokenghs_ABCDEF...The installation access token. Prefix ghs_ = server-to-server.
expires_atISO 8601 timestampExactly 1 hour from creation. Non-renewable — you must create a new one.
permissionsObjectThe effective permissions this token has (may be a subset of the App’s full permissions).
repository_selection"selected" or "all"Whether token can access all installed repos or just the ones listed.
repositoriesArrayThe specific repos this token can access (only when selected).

Citation: Generating an installation access token for a GitHub App — GitHub Docs
Citation: REST API — Create an installation access token — GitHub Docs

3.7 Step 5: Using the Installation Token to Call GitHub APIs

Now Azure DevOps has a ghs_ token that can actually do things. Here’s how it’s used for the two most common pipeline operations:

Cloning source code:

The pipeline agent injects the token into the git clone command:

git -c http.extraheader="AUTHORIZATION: basic $(echo -n 'x-access-token:ghs_ABCDEF...' | base64)" \
clone https://github.com/your-github-org/my-repo.git

Or equivalently:

git clone https://x-access-token:ghs_ABCDEF...@github.com/your-github-org/my-repo.git

GitHub’s git server validates the ghs_ token against the installation’s permissions and repo access. If contents: read is granted for this repo, the clone succeeds.

Creating a check run (reporting build status):

POST https://api.github.com/repos/your-github-org/my-repo/checks
Authorization: token ghs_ABCDEFghijklmnop1234567890xyz
Accept: application/vnd.github+json
Content-Type: application/json

{
  "name": "Azure Pipelines - my-pipeline",
  "head_sha": "abc123def456...",
  "status": "in_progress",
  "details_url": "https://dev.azure.com/your-org/your-project/_build/results?buildId=789",
  "output": {
    "title": "Build in progress",
    "summary": "Pipeline 'my-pipeline' is running..."
  }
}

Later, when the build completes:

PATCH https://api.github.com/repos/your-github-org/my-repo/check-runs/{check_run_id}
Authorization: token ghs_ABCDEFghijklmnop1234567890xyz
Accept: application/vnd.github+json
Content-Type: application/json

{
  "status": "completed",
  "conclusion": "success",
  "output": {
    "title": "Build succeeded",
    "summary": "All 247 tests passed. Build completed in 3m 42s."
  }
}

This is what makes the ✅ green check appear on your commit/PR in GitHub.

Citation: Building CI checks with a GitHub App — GitHub Docs

3.8 The Complete Timeline for a Single Pipeline Run

Putting it all together — here’s every authentication step that happens from trigger to completion:

Time Who Does What
───── ───────────────────── ──────────────────────────────────────────────────
T+0s Developer git push to GitHub
T+0.1s GitHub Receives push, fires webhook to Azure Pipelines App endpoint
T+0.5s Azure DevOps Receives webhook, matches pipeline triggers in YAML
T+1s Azure DevOps Decides to queue a pipeline run
├─ Needs to report status → must authenticate as the App
T+1.1s Azure DevOps Loads private key from secure storage (Key Vault)
T+1.2s Azure DevOps Builds JWT: { iss: "<app_id>", iat: now-60, exp: now+600 }
T+1.3s Azure DevOps Signs JWT with private key (RS256)
T+1.4s Azure DevOps ──JWT──▶ POST /app/installations/98765/access_tokens
T+1.6s GitHub Verifies JWT signature with public key ✓
T+1.7s GitHub ──ghs_token──▶ Returns: { "token": "ghs_ABCDEF...", "expires_at": "T+3601s" }
T+2s Azure DevOps POST /repos/org/repo/check-runs
(status: "queued", using ghs_ token)
T+5s Pipeline Agent Starts running on hosted/self-hosted agent
T+5.5s Pipeline Agent git clone using ghs_ token → clones source code
T+6s Pipeline Agent Runs build/test steps
T+240s Pipeline Agent Build completes (success)
T+241s Azure DevOps PATCH /repos/org/repo/check-runs/{id}
(status: "completed", conclusion: "success", using ghs_ token)
T+3601s ghs_ token expires (doesn't matter — build is done)
Next pipeline run will generate a fresh JWT and fresh token.

3.9 Token Lifecycle Summary

PRIVATE KEY (never expires, never transmitted)
▼ signs
JWT (expires in 10 minutes, transmitted once)
▼ exchanged for
INSTALLATION TOKEN ghs_... (expires in 1 hour, used for API calls)
▼ authorizes
API CALLS (clone, status, checks, comments)
TokenLifetimeWhere It LivesTransmitted?
Private Key (.pem)Indefinite (until revoked)Azure DevOps Key Vault (Microsoft-managed)Never
JWT10 minutes maxGenerated in memory, sent onceYes — to GitHub’s API in the Authorization header
Installation Token (ghs_)1 hourPipeline agent memoryYes — to GitHub’s API and git server

Citations (all from GitHub Docs):
About authentication with a GitHub App — the three authentication levels.
Generating a JWT for a GitHub App — JWT structure, claims, RS256 signing.
Managing private keys for GitHub Apps — key generation, storage, rotation.
Generating an installation access token — the token exchange API.
Authenticating to the REST API — which endpoints accept which token types.
Behind GitHub’s new authentication token formats — the ghs_ghp_gho_ prefix system.
Building CI checks with a GitHub App — creating check runs with an installation token.


4. The “Azure Pipelines” GitHub App — Role & Internals

4.1 What Is It?

The Azure Pipelines app (https://github.com/apps/azure-pipelines) is a first-party GitHub App published by Microsoft. It’s listed on the GitHub Marketplace and is free.

It is not a GitHub Action — it is a GitHub App, which is a fundamentally different integration mechanism. A GitHub App is an application with its own identity, its own permissions, and its own webhook subscriptions.

4.2 What It Does (Comprehensive List)

CapabilityHow
Triggers pipeline runsSubscribes to pushpull_request events via webhooks
Reports build statusUses GitHub Checks API to create check_run and check_suite entries
Clones source codeGenerates short-lived installation tokens to authenticate git clone
Comments on PRsPosts pipeline results (pass/fail, logs link) on PR conversations
Manages webhooksAutomatically creates and manages webhook subscriptions on repos it’s installed on
Enables re-run from GitHubBecause it uses the Checks API, users can click “Re-run” directly in GitHub’s Checks tab

4.3 Permissions It Requests

When you install the Azure Pipelines GitHub App, it requests these permissions:

PermissionAccess LevelPurpose
ChecksRead & WriteCreate and update check runs/suites for pipeline results
ContentsReadClone repository code, read azure-pipelines.yml
IssuesRead & WritePost comments about build results on issues
Pull requestsRead & WriteTrigger PR validation builds, post status comments
Commit statusesRead & WriteSet commit status (success/failure/pending)
MetadataReadRead repo metadata (required for all GitHub Apps)

4.4 Webhook Events It Subscribes To

When installed on a repo, the app automatically registers to receive these webhook events:

EventWhen It FiresWhat Azure Pipelines Does
pushCode is pushed to any branchEvaluates CI trigger rules, queues pipeline if matched
pull_requestPR is opened, updated, synchronized, reopenedEvaluates PR trigger rules, queues validation build
check_suiteA check suite is requested or rerequestedReruns the pipeline (when user clicks “Re-run” in GitHub)
check_runA check run is rerequestedReruns a specific pipeline stage
issue_commentComment on a PR (e.g., /azp run)Processes Azure Pipelines slash commands to manually trigger runs

4.5 How It Differs From OAuth/PAT Connections

Citation: How to streamline GitHub API calls in Azure Pipelines — GitHub Blog


5. Trigger Mechanism — How a Commit Becomes a Pipeline Run

This section traces the exact sequence of events from a developer pushing a commit to a pipeline starting.

5.1 The Full Sequence (GitHub App-based)

5.2 Identities Involved in the Trigger

StepIdentityTypeRole
1. git pushThe developerHuman (GitHub user)Pushes code to the branch
2–3. Webhook deliveryGitHub platformSystemEvaluates webhook subscriptions and delivers the POST
3. Webhook receiverAzure Pipelines serviceSystem (Microsoft-hosted)Receives and validates the webhook payload
4–6. Trigger evaluationAzure Pipelines orchestratorSystemMatches webhook to pipeline definitions, evaluates YAML triggers
7. Report statusAzure Pipelines App identityGitHub App (azure-pipelines)Creates a check run on the commit via Checks API
8. Execute buildPipeline AgentSystem (Microsoft-hosted or self-hosted)Runs the pipeline YAML steps
9–10. Clone codeAzure Pipelines AppGitHub AppGenerates installation token, uses it to git clone
12. Report resultAzure Pipelines App identityGitHub AppUpdates check run with final status

5.3 The Webhook Payload (Push Event)

When GitHub sends the webhook, the HTTP request looks like this:

POST https://dev.azure.com/{org}/_apis/Hooks/IncomingWebhook?...
Content-Type: application/json
X-GitHub-Event: push
X-GitHub-Delivery: <unique-guid>
X-Hub-Signature-256: sha256=<HMAC-signature>

{
  "ref": "refs/heads/main",
  "before": "abc123...",
  "after": "def456...",
  "repository": {
    "id": 12345,
    "full_name": "my-org/my-repo",
    "private": true
  },
  "pusher": {
    "name": "developer-username",
    "email": "dev@example.com"
  },
  "commits": [
    {
      "id": "def456...",
      "message": "Fix login bug AB#1234",
      "author": { "name": "Dev", "email": "dev@example.com" }
    }
  ]
}

Webhook security:

  • The X-Hub-Signature-256 header contains an HMAC-SHA256 signature of the payload body, signed with a shared secret established when the webhook was created.
  • Azure Pipelines validates this signature to ensure the webhook is genuinely from GitHub and hasn’t been tampered with.

5.4 What Happens for OAuth/PAT Connections (Without the GitHub App)

When using OAuth or PAT instead of the GitHub App:

  1. Azure DevOps creates a webhook on the GitHub repo during pipeline setup (using the OAuth token’s admin:repo_hook scope or the PAT’s permissions).
  2. The webhook URL points to Azure DevOps’ incoming webhook endpoint.
  3. On push, GitHub fires the webhook → Azure DevOps processes it the same way.
  4. Status is reported via the Commit Status API (older, simpler) rather than the Checks API (richer).
  5. Source code is cloned using the stored OAuth token or PAT instead of an installation access token.

The key difference: the webhook is a repo webhook (owned by the repo) rather than an App webhook (managed by the GitHub App installation).

Citations:
Webhook events and payloads — GitHub Docs
Triggers in Azure Pipelines — Microsoft Learn


6. Azure Boards AB# Linking — Multi-Org Resolution

6.1 How AB# Works

The Azure Boards GitHub App (https://github.com/apps/azure-boards) is a separate GitHub App from Azure Pipelines. Its sole purpose is work-item linking.

The flow:

Developer GitHub Azure Boards App Azure DevOps
│ │ │ │
│ commit: "Fix AB#1234" │ │ │
│────────────────────────▶│ │ │
│ │ webhook: push event │ │
│ │───────────────────────────▶│ │
│ │ │ │
│ │ │ Parse commit message │
│ │ │ Find "AB#1234" │
│ │ │ │
│ │ │ POST work item link │
│ │ │───────────────────────▶│
│ │ │ │
│ │ │ │ Create "Development"
│ │ │ │ link on WI #1234
  1. Developer pushes a commit with AB#1234 in the message (or PR title/description).
  2. GitHub fires a push (or pull_request) webhook to the Azure Boards App.
  3. The Azure Boards App parses the commit messages and PR body for the AB#<id> pattern.
  4. It calls the Azure DevOps REST API to create a Development link from work item 1234 to the GitHub commit/PR.
  5. Optionally, if the message contains Fixes AB#1234 or Fixed AB#1234, and the commit lands on the default branch, the work item’s state is transitioned to Done/Resolved.

6.2 The Multi-Org Problem: 5 Azure DevOps Orgs, 1 GitHub Repo

This is a critical design limitation:

“You can connect a GitHub repository to only one Azure DevOps organization and project. If you connect the same GitHub repository to projects defined in two or more Azure DevOps organizations, you might experience unexpected AB# mention linking.”
— Microsoft Docs: Azure Boards-GitHub Integration

What actually happens internally:

When you install the Azure Boards app on a GitHub repo and connect it to an Azure DevOps project, the connection is stored as a mapping:

GitHub Repo (org/repo) ──────▶ Azure DevOps Org + Project

If you connect the same repo to 5 different Azure DevOps organizations:

  1. Each AzDO org installs (or reuses) the Azure Boards GitHub App.
  2. Each installation creates its own webhook subscription for that repo.
  3. When a commit with AB#1234 is pushed, GitHub fires the webhook to all registered webhook endpoints.
  4. Each of the 5 Azure DevOps organizations receives the webhook independently.
  5. Each org looks up work item #1234 in its own database.
  6. If org A has a work item 1234 and org C also has a work item 1234, both get linked to the same commit — even though they might be completely different work items.

GitHub does NOT resolve which org the AB# belongs to.

The AB# syntax has no org qualifier — it’s just a number. GitHub simply broadcasts the webhook. The resolution (or lack thereof) happens entirely on the Azure DevOps side, where each org independently processes the event.

                              ┌──────────────────┐
                              │  GitHub fires    │
                              │  webhook to ALL  │
                              │  subscribers     │
                              └────────┬─────────┘
                    ┌──────────────────┼──────────────────┐
                    ▼                  ▼                  ▼
            ┌──────────────┐  ┌──────────────┐   ┌──────────────┐
            │  AzDO Org A  │  │  AzDO Org B  │   │  AzDO Org C  │
            │  Has WI#1234 │  │  No WI#1234  │   │  Has WI#1234 │
            │  → LINKED ✓  │  │  → ignored   │   │  → LINKED ✓  │
            └──────────────┘  └──────────────┘   └──────────────┘
                                                     (WRONG link!)

6.3 Recommended Architecture for Multi-Org

ApproachHowProsCons
One repo → One AzDO orgEach repo connects to exactly one AzDO org/projectClean, no ambiguityLimits flexibility
Fork per orgFork the repo for each AzDO org, connect each fork to its orgFull isolationMerge overhead, divergent histories
Repo-per-teamSplit monorepo into per-team repos, each linked to its AzDO orgClean boundariesMay not fit your codebase structure
Don’t use AB# cross-orgUse direct URL links in PRs instead of AB# syntaxWorks with multi-orgLoses automatic linking magic

Citations:
Azure Boards-GitHub integration overview — Microsoft Learn
Connecting Azure Boards GitHub App to Multiple Azure DevOps Orgs — Josh Johanning
Multiple Azure Boards organizations / one GitHub org — Joost Voskuil


7. References

Microsoft Official Documentation

#TitleURL
1Build GitHub repositories — Azure Pipelineshttps://learn.microsoft.com/en-us/azure/devops/pipelines/repos/github?view=azure-devops
2GitHub integration overview — Azure DevOpshttps://learn.microsoft.com/en-us/azure/devops/cross-service/github-integration?view=azure-devops
3Triggers in Azure Pipelineshttps://learn.microsoft.com/en-us/azure/devops/pipelines/build/triggers?view=azure-devops
4Azure Boards-GitHub integrationhttps://learn.microsoft.com/en-us/azure/devops/boards/github/?view=azure-devops
5Link GitHub commits/PRs to work itemshttps://learn.microsoft.com/en-us/azure/devops/boards/github/link-to-from-github?view=azure-devops
6Connect Azure Boards to GitHubhttps://learn.microsoft.com/en-us/azure/devops/boards/github/connect-to-github?view=azure-devops
7Install the Azure Boards App for GitHubhttps://learn.microsoft.com/en-us/azure/devops/boards/github/install-github-app?view=azure-devops

GitHub Documentation

#TitleURL
8Webhook events and payloadshttps://docs.github.com/en/webhooks/webhook-events-and-payloads
9Using webhooks with GitHub Appshttps://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/using-webhooks-with-github-apps
10Generating a JWT for a GitHub Apphttps://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app
11Generating an installation access tokenhttps://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token-for-a-github-app
12Managing personal access tokenshttps://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens
13Choosing permissions for a GitHub Apphttps://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app
14Building CI checks with a GitHub Apphttps://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/building-ci-checks-with-a-github-app

Community / Blog Posts

#TitleURL
15How to streamline GitHub API calls in Azure Pipelines — GitHub Bloghttps://github.blog/enterprise-software/ci-cd/how-to-streamline-github-api-calls-in-azure-pipelines/
16Connecting Azure Boards to Multiple AzDO Orgs — Josh Johanninghttps://josh-ops.com/posts/github-connecting-to-azure-boards-multiple-orgs/
17Multiple Azure Boards orgs / one GitHub org — Joost Voskuilhttps://foxhole.nl/blog/2025/azureboards-github-integration-advanced/
18Creating a GitHub App-based AzDO Service Connection — Richard Fennellhttps://blogs.blackmarble.co.uk/rfennell/setting-up-github-app-ado-service-connection-on-another-org/
19GitHub integration with Azure Pipelines — Hands-on Labshttps://www.azuredevopslabs.com/labs/vstsextend/github-azurepipelines/

Leave a comment