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
- Architecture Overview
- Service Connections — What Happens Under the Hood
- Deep Dive: GitHub App Authentication — The Full Machinery
- The “Azure Pipelines” GitHub App — Role & Internals
- Trigger Mechanism — How a Commit Becomes a Pipeline Run
- Azure Boards
AB#Linking — Multi-Org Resolution - References
1. Architecture Overview
When you use GitHub for source code and Azure DevOps for boards + pipelines, three integration surfaces exist:
| Surface | GitHub Side | Azure DevOps Side | Communication Channel |
|---|---|---|---|
| Pipeline ↔ Repo | GitHub App / OAuth App / Webhook | Service Connection (stores credentials) | Webhooks (GitHub → AzDO) + REST API (AzDO → GitHub) |
| Board ↔ Repo | Azure Boards GitHub App | Board project connection | Webhooks (GitHub → AzDO) |
| Status Checks | Checks API / Commit Status API | Pipeline agent reports back | REST 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 viaAB#.
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.
| Detail | Value |
|---|---|
| OAuth App Name | Azure Pipelines (not “Azure DevOps” — this is the actual name as registered on GitHub) |
| Registered By | Microsoft (centrally managed) |
| Registered On | GitHub’s OAuth App registry |
client_id | 0d4949be3b947c3ce4a5 — issued by GitHub, hardcoded in Azure DevOps backend |
client_secret | Issued by GitHub, stored encrypted on Azure DevOps servers (never exposed to users) |
| Callback URL | Points back to Azure DevOps (e.g., https://dev.azure.com:443/{org}/{projectId}/_admin/_services/callback/github) |
| Visible To Users | Yes — appears in github.com/settings/applications → “Authorized OAuth Apps” as “Azure Pipelines” after consent |
| Management URL | github.com/settings/connections/applications/0d4949be3b947c3ce4a5 |
Citation proving client ID ownership: The official Microsoft documentation at
MicrosoftDocs/azure-devops-docs/docs/pipelines/repos/github.mdexplicitly references this client ID four times, each time directing users to manage the Azure Pipelines OAuth App permissions athttps://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 Field | Purpose |
|---|---|
ConfigurationId | Unique ID for the service connection being created |
ProjectId | The Azure DevOps project GUID |
CallbackUri | Where 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) |
__RequestVerificationToken | CSRF protection — AzDO validates this when the callback arrives |
EndpointType | Tells 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:
| Scope | What It Grants |
|---|---|
repo | Full read/write access to all repos the user can access (clone source, read YAML) |
read:user | Read the user’s GitHub profile information |
user:email | Read the user’s email addresses (used to match identity across systems) |
admin:repo_hook | Create and delete webhooks on repositories (needed for pipeline triggers) |
Citation: Authorizing OAuth Apps — GitHub Docs
Step-by-step internal flow:

- Redirect to GitHub: Azure DevOps redirects your browser to
github.com/login/oauth/authorizewith the pre-registeredclient_idthat GitHub itself issued to Microsoft. GitHub looks up thisclient_idin its OAuth App registry, finds the “Azure DevOps” application, and renders the consent screen showing the app name, logo, and requested permissions. - 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).
- Authorization code returned: GitHub redirects back to Azure DevOps with a one-time authorization code.
- 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. - 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:
| Field | Value |
|---|---|
| Connection Type | GitHub |
| Auth Scheme | OAuth |
| OAuth Access Token | Encrypted, stored in Azure DevOps credential store |
| Refresh Token | Encrypted (if provided by GitHub; GitHub classic OAuth does not always issue refresh tokens) |
| Token Owner | The GitHub user who performed the consent |
| Scopes | repo, admin:repo_hook, repo: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:
- You generate a PAT in GitHub → Settings → Developer settings → Personal access tokens.
- You paste the PAT into the Azure DevOps service connection form.
- Azure DevOps stores it encrypted in its credential vault.
What the service connection holds:
| Field | Value |
|---|---|
| Connection Type | GitHub |
| Auth Scheme | Token |
| Personal Access Token | The raw PAT string, encrypted at rest |
| Token Owner | The GitHub user who created the PAT |
| Required Scopes | repo (full repo access), admin:repo_hook, read:user, user: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
reposcope 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
.pemfile, 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
.pemfile. - 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:
| Scenario | Who owns the App? | Who generated the private key? | Where is it stored? |
|---|---|---|---|
| Built-in Azure Pipelines App | Microsoft | Microsoft | Azure DevOps backend (Microsoft-managed) |
| Custom GitHub App service connection | You | You (downloaded .pem from GitHub) | You upload it to Azure DevOps service connection |
Internal token flow (asymmetric — no shared secrets):

- 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).
- 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.
- 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. - 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:
| Field | Value |
|---|---|
| Connection Type | GitHub |
| Auth Scheme | GitHubApp |
| App ID | The numeric ID of the Azure Pipelines GitHub App (managed by Microsoft) |
| Installation ID | The ID of this specific installation in your GitHub org |
| Private Key | Not stored in your service connection — it’s on Microsoft’s backend |
For a custom GitHub App service connection you create yourself:
| Field | Value |
|---|---|
| Connection Type | GitHub |
| Auth Scheme | GitHubApp |
| App ID | Your custom App’s numeric ID |
| Installation ID | Your 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:
| Prefix | Token Type | Example Use |
|---|---|---|
ghp_ | Personal Access Token (classic) | Developer automation |
github_pat_ | Fine-grained Personal Access Token | Scoped developer access |
gho_ | OAuth Access Token | OAuth App integrations (like the Azure Pipelines OAuth flow) |
ghu_ | GitHub App User-to-Server Token | App acting on behalf of a specific user |
ghs_ | GitHub App Installation Token (server-to-server) | This is what Azure Pipelines uses |
ghr_ | Refresh Token | Token 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"}
alg: RS256 — RSA signature with SHA-256 hash. This is the only algorithm GitHub accepts.typ: AlwaysJWT.
This is Base64URL-encoded to produce the first segment:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9
The Payload (Claims)
{ "iat": 1710700000, "exp": 1710700600, "iss": "12345"}
| Claim | Type | Value | Requirement |
|---|---|---|---|
iss | String | The GitHub App’s numeric App ID (e.g., "12345") | Required. Found in the App’s settings page on GitHub. |
iat | Integer | Unix timestamp of when the JWT was issued. GitHub recommends setting this to current time − 60 seconds to account for clock drift between servers. | Required. |
exp | Integer | Unix timestamp of when the JWT expires. Maximum 10 minutes after iat. GitHub rejects JWTs with longer expiry. | Required. |
Important: The
issclaim 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:
- Concatenate the Base64URL-encoded header and payload with a dot separator.
- Hash the result with SHA-256.
- Sign the hash with the RSA private key using PKCS#1 v1.5 padding.
- 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/appAuthorization: Bearer eyJhbGciOiJSUzI1NiIs...Accept: application/vnd.github+json
What GitHub does when it receives this request:
- Decodes the JWT header → extracts
alg: RS256. - Decodes the JWT payload → extracts
iss: "12345"(App ID). - Looks up App ID
12345in its database → finds the corresponding public key. - Verifies the signature: takes
Base64URL(header).Base64URL(payload), hashes it, and checks the signature against the public key. - Checks
exp→ if the JWT has expired, rejects with401. - Checks
iat→ if it’s too far in the future, rejects. - 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):
| Endpoint | Purpose |
|---|---|
GET /app | Get the App’s own metadata |
GET /app/installations | List all organizations/accounts where the App is installed |
GET /app/installations/{id} | Get details of a specific installation |
POST /app/installations/{id}/access_tokens | Create 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.
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/installationsAuthorization: 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:
| Property | Value | Meaning |
|---|---|---|
token | ghs_ABCDEF... | The installation access token. Prefix ghs_ = server-to-server. |
expires_at | ISO 8601 timestamp | Exactly 1 hour from creation. Non-renewable — you must create a new one. |
permissions | Object | The 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. |
repositories | Array | The 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 GitHubT+0.1s GitHub Receives push, fires webhook to Azure Pipelines App endpointT+0.5s Azure DevOps Receives webhook, matches pipeline triggers in YAMLT+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_tokensT+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 agentT+5.5s Pipeline Agent git clone using ghs_ token → clones source codeT+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) │ ▼ signsJWT (expires in 10 minutes, transmitted once) │ ▼ exchanged forINSTALLATION TOKEN ghs_... (expires in 1 hour, used for API calls) │ ▼ authorizesAPI CALLS (clone, status, checks, comments)
| Token | Lifetime | Where It Lives | Transmitted? |
|---|---|---|---|
| Private Key (.pem) | Indefinite (until revoked) | Azure DevOps Key Vault (Microsoft-managed) | Never |
| JWT | 10 minutes max | Generated in memory, sent once | Yes — to GitHub’s API in the Authorization header |
Installation Token (ghs_) | 1 hour | Pipeline agent memory | Yes — 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 — theghs_,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)
| Capability | How |
|---|---|
| Triggers pipeline runs | Subscribes to push, pull_request events via webhooks |
| Reports build status | Uses GitHub Checks API to create check_run and check_suite entries |
| Clones source code | Generates short-lived installation tokens to authenticate git clone |
| Comments on PRs | Posts pipeline results (pass/fail, logs link) on PR conversations |
| Manages webhooks | Automatically creates and manages webhook subscriptions on repos it’s installed on |
| Enables re-run from GitHub | Because 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:
| Permission | Access Level | Purpose |
|---|---|---|
| Checks | Read & Write | Create and update check runs/suites for pipeline results |
| Contents | Read | Clone repository code, read azure-pipelines.yml |
| Issues | Read & Write | Post comments about build results on issues |
| Pull requests | Read & Write | Trigger PR validation builds, post status comments |
| Commit statuses | Read & Write | Set commit status (success/failure/pending) |
| Metadata | Read | Read 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:
| Event | When It Fires | What Azure Pipelines Does |
|---|---|---|
push | Code is pushed to any branch | Evaluates CI trigger rules, queues pipeline if matched |
pull_request | PR is opened, updated, synchronized, reopened | Evaluates PR trigger rules, queues validation build |
check_suite | A check suite is requested or rerequested | Reruns the pipeline (when user clicks “Re-run” in GitHub) |
check_run | A check run is rerequested | Reruns a specific pipeline stage |
issue_comment | Comment 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
| Step | Identity | Type | Role |
|---|---|---|---|
1. git push | The developer | Human (GitHub user) | Pushes code to the branch |
| 2–3. Webhook delivery | GitHub platform | System | Evaluates webhook subscriptions and delivers the POST |
| 3. Webhook receiver | Azure Pipelines service | System (Microsoft-hosted) | Receives and validates the webhook payload |
| 4–6. Trigger evaluation | Azure Pipelines orchestrator | System | Matches webhook to pipeline definitions, evaluates YAML triggers |
| 7. Report status | Azure Pipelines App identity | GitHub App (azure-pipelines) | Creates a check run on the commit via Checks API |
| 8. Execute build | Pipeline Agent | System (Microsoft-hosted or self-hosted) | Runs the pipeline YAML steps |
| 9–10. Clone code | Azure Pipelines App | GitHub App | Generates installation token, uses it to git clone |
| 12. Report result | Azure Pipelines App identity | GitHub App | Updates 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-256header 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:
- Azure DevOps creates a webhook on the GitHub repo during pipeline setup (using the OAuth token’s
admin:repo_hookscope or the PAT’s permissions). - The webhook URL points to Azure DevOps’ incoming webhook endpoint.
- On push, GitHub fires the webhook → Azure DevOps processes it the same way.
- Status is reported via the Commit Status API (older, simpler) rather than the Checks API (richer).
- 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
- Developer pushes a commit with
AB#1234in the message (or PR title/description). - GitHub fires a
push(orpull_request) webhook to the Azure Boards App. - The Azure Boards App parses the commit messages and PR body for the
AB#<id>pattern. - It calls the Azure DevOps REST API to create a Development link from work item 1234 to the GitHub commit/PR.
- Optionally, if the message contains
Fixes AB#1234orFixed 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:
- Each AzDO org installs (or reuses) the Azure Boards GitHub App.
- Each installation creates its own webhook subscription for that repo.
- When a commit with
AB#1234is pushed, GitHub fires the webhook to all registered webhook endpoints. - Each of the 5 Azure DevOps organizations receives the webhook independently.
- Each org looks up work item #1234 in its own database.
- 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
| Approach | How | Pros | Cons |
|---|---|---|---|
| One repo → One AzDO org | Each repo connects to exactly one AzDO org/project | Clean, no ambiguity | Limits flexibility |
| Fork per org | Fork the repo for each AzDO org, connect each fork to its org | Full isolation | Merge overhead, divergent histories |
| Repo-per-team | Split monorepo into per-team repos, each linked to its AzDO org | Clean boundaries | May not fit your codebase structure |
Don’t use AB# cross-org | Use direct URL links in PRs instead of AB# syntax | Works with multi-org | Loses 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
| # | Title | URL |
|---|---|---|
| 1 | Build GitHub repositories — Azure Pipelines | https://learn.microsoft.com/en-us/azure/devops/pipelines/repos/github?view=azure-devops |
| 2 | GitHub integration overview — Azure DevOps | https://learn.microsoft.com/en-us/azure/devops/cross-service/github-integration?view=azure-devops |
| 3 | Triggers in Azure Pipelines | https://learn.microsoft.com/en-us/azure/devops/pipelines/build/triggers?view=azure-devops |
| 4 | Azure Boards-GitHub integration | https://learn.microsoft.com/en-us/azure/devops/boards/github/?view=azure-devops |
| 5 | Link GitHub commits/PRs to work items | https://learn.microsoft.com/en-us/azure/devops/boards/github/link-to-from-github?view=azure-devops |
| 6 | Connect Azure Boards to GitHub | https://learn.microsoft.com/en-us/azure/devops/boards/github/connect-to-github?view=azure-devops |
| 7 | Install the Azure Boards App for GitHub | https://learn.microsoft.com/en-us/azure/devops/boards/github/install-github-app?view=azure-devops |
GitHub Documentation
Community / Blog Posts
| # | Title | URL |
|---|---|---|
| 15 | How to streamline GitHub API calls in Azure Pipelines — GitHub Blog | https://github.blog/enterprise-software/ci-cd/how-to-streamline-github-api-calls-in-azure-pipelines/ |
| 16 | Connecting Azure Boards to Multiple AzDO Orgs — Josh Johanning | https://josh-ops.com/posts/github-connecting-to-azure-boards-multiple-orgs/ |
| 17 | Multiple Azure Boards orgs / one GitHub org — Joost Voskuil | https://foxhole.nl/blog/2025/azureboards-github-integration-advanced/ |
| 18 | Creating a GitHub App-based AzDO Service Connection — Richard Fennell | https://blogs.blackmarble.co.uk/rfennell/setting-up-github-app-ado-service-connection-on-another-org/ |
| 19 | GitHub integration with Azure Pipelines — Hands-on Labs | https://www.azuredevopslabs.com/labs/vstsextend/github-azurepipelines/ |