GitHub Actions ile Deploy 1: OIDC ile Azure App Service ve Şifresiz Cloud Erişimi
OIDC ile job her çalışmada GitHub'dan imzalı, kısa ömürlü bir token alır ve Azure bunu federated credential ile doğrulayıp client secret olmadan erişim verir. id-token: write, sub claim formatları, Temmuz 2026'daki ID'li subject değişikliği, custom property claim'leri, staging slot'a deploy, smoke test ve swap; AWS ve Google Cloud karşılıkları.
Sürüm notu: Bu yazıdaki örnekler act 0.2.97, actionlint 1.7.12, Docker 29.2, .NET 10, azure/login@v3 (v3.1.0) ve azure/webapps-deploy@v3 (v3.0.8) ile denenmiştir. Azure tarafı ve token alışverişi GitHub'da ve Azure'da koşulmamıştır; claim formatları ve tarihler Kasım 2026 itibarıyla GitHub ve Microsoft dokümantasyonundan doğrulanmıştır.
Önce Tahmin Et
Azure'da Bookstore için bir app registration açtın ve ona tek bir federated credential ekledin; subject alanı repo:octo-org/bookstore:ref:refs/heads/main. Deploy workflow'un main'den tetikleniyor ve job'da environment: production var. azure/login adımı başarılı olur mu?
İkinci soru: aynı repository'yi Ağustos 2026'da sıfırdan oluşturdun ve subject'i yine isimle, repo:octo-org/bookstore:environment:production diye yazdın. Bu kez ne olur?
Secret'lı Girişin Sorunu
Bir önceki yazıda deploy job'ı DEPLOY_TOKEN adlı bir environment secret'ına dayanıyordu. Azure'a klasik yoldan bağlanmak da böyle: bir service principal oluşturursun, ona client secret üretirsin ve bu secret'ı GitHub'a koyarsın. Çalışır, ama üç sorunu var. Secret'ın bir son kullanma tarihi vardır ve o tarih genellikle bir cuma akşamı deploy'u kırarak hatırlanır. Secret bir kere sızarsa (log'a basılması, eski bir fork, kötü niyetli bir action) sahibi onu GitHub dışından, istediği makineden, son kullanma tarihine kadar kullanabilir. Üçüncüsü, secret "hangi workflow, hangi branch, hangi environment" bilgisini taşımaz; elinde olan herkes aynı yetkiye sahiptir.
OIDC (OpenID Connect) bu modeli tersine çeviriyor. Repository'de saklanan bir credential yok. Her job çalışırken GitHub'dan imzalı, kısa ömürlü bir kimlik token'ı ister; cloud tarafı bu token'ın içindeki bilgilere bakar ve önceden tanımladığın güven ilişkisine uyuyorsa kendi kısa ömürlü access token'ını verir. Sızacak kalıcı bir şey kalmıyor; sızan token da dakikalar içinde ölüyor ve yalnızca tanımladığın repository ve environment adına geçerli. Tool'dan bağımsız tarafı, yani kısa ömürlü credential'ın neden daha güvenli olduğu, CI (Continuous Integration) ve CD (Continuous Delivery) kavramlarını anlatan CI/CD Temelleri serisinin pipeline güvenliği yazısında; burada mekaniğe ve Azure'a odaklanıyoruz.
OIDC Akışı: Dört Adımda Şifresiz Giriş

Job permissions: id-token: write ile başladığında runner'a iki environment variable verilir: ACTIONS_ID_TOKEN_REQUEST_URL ve ACTIONS_ID_TOKEN_REQUEST_TOKEN. Bir step (veya azure/login gibi bir action) bu adrese istek atıp istediği audience için bir JWT (JSON Web Token) alır. Token'ı imzalayan GitHub'ın OIDC provider'ı; issuer değeri https://token.actions.githubusercontent.com. Cloud tarafı (Azure'da Microsoft Entra ID) token'ın imzasını provider'ın yayınladığı public key'lerle doğrular, ardından iss, aud ve sub claim'lerini senin tanımladığın federated credential ile karşılaştırır. Eşleşirse job'a, yalnızca o job süresince yaşayan bir access token döner.
Önemli bir ayrıntı: id-token: write verdiğinde job'a bir yetki eklemiş olmuyorsun; yalnızca kimlik belgesi isteme hakkı veriyorsun. Belgeyle ne yapılabileceğine cloud tarafı karar veriyor. Bu yüzden permissions: bloğunu job seviyesinde, yalnızca deploy job'larına yaz. Unutma: bir permission yazdığın anda yazmadıkların none olur, actions/checkout kullanacaksan contents: read'i de ekle.
Token'ın İçinde Ne Var: Claim'ler ve Subject
Token'ı görmek için küçük bir probe workflow'u yazdım. Token'ın kendisini değil, yalnızca payload'daki seçili claim'leri basıyor:
name: oidc-probe
on: workflow_dispatch
permissions:
id-token: write
jobs:
probe:
runs-on: ubuntu-latest
steps:
- name: Request an ID token and print selected claims
run: |
if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then
echo "No OIDC endpoint: missing id-token permission or not running on GitHub"
exit 1
fi
token=$(curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" | jq -r .value)
payload=$(echo "$token" | cut -d. -f2 | tr '_-' '/+')
while [ $(( ${#payload} % 4 )) -ne 0 ]; do payload="$payload="; done
echo "$payload" | base64 -d | jq '{sub, aud, environment, ref, repository_id, exp}'
act ile koşunca sonuç beklendiği gibi:
[oidc-probe/probe] ⭐ Run Main Request an ID token and print selected claims
[oidc-probe/probe] | No OIDC endpoint: missing id-token permission or not running on GitHub
[oidc-probe/probe] ❌ Failure - Main Request an ID token and print selected claims [135.677ms]
[oidc-probe/probe] 🏁 Job failed
act'in bir OIDC provider'ı yok, dolayısıyla bu iki variable hiç tanımlanmıyor. Aynı mesajı GitHub'da id-token: write vermeyi unuttuğunda da görürsün. Script'in base64url çözme kısmını, elle ürettiğim imzasız bir token ile runner image'ında ayrıca test ettim; JWT payload'ı base64url kodlu ve padding'siz olduğu için tr ve = tamamlaması şart.
Gerçek bir GitHub token'ında ne olduğunu dokümantasyondaki örnekten biliyoruz: sub, aud, iss, ref, sha, repository, repository_id, repository_owner_id, environment, job_workflow_ref, runner_environment ve daha fazlası. Güven kararını neredeyse her zaman sub belirler ve formatı job'ın bağlamına göre değişir:
- Job bir environment kullanıyorsa:
repo:octo-org/bookstore:environment:production - Environment yoksa ve branch push'uysa:
repo:octo-org/bookstore:ref:refs/heads/main - Environment yoksa ve tag push'uysa:
repo:octo-org/bookstore:ref:refs/tags/v1.4.0 - Pull request event'iyse:
repo:octo-org/bookstore:pull_request
Tahminin ilk sorusunun cevabı ilk satırda. Job environment: production kullandığı anda sub artık ref:refs/heads/main değil, environment:production olur. Branch'e göre yazılmış federated credential eşleşmez ve azure/login, README'sinde de anlatılan AADSTS700213: No matching federated identity record found for presented assertion subject hatasıyla düşer. Environment kullanan job'lar için credential'ı environment'a göre yaz; bu aynı zamanda daha güvenli, çünkü environment'ın protection rule'ları artık cloud erişiminin de ön koşulu.
İkinci sorunun cevabı 2026'nın en önemli OIDC değişikliğinde. 15 Temmuz 2026'dan sonra oluşturulan, yeniden adlandırılan veya transfer edilen repository'lerde varsayılan sub, isimlerin yanına değişmeyen sayısal ID'leri ekliyor: repo:octo-org@5544123/bookstore@821093847:environment:production. Sebebi "subject recycling": bir organization adı bırakılıp başkası tarafından alınırsa, isimle yazılmış bir trust policy yeni sahibin repository'sine de güvenebilir. Ağustos'ta oluşturduğun repository yeni formatı kullandığı için isimle yazdığın subject eşleşmez ve yine AADSTS700213 alırsın. Eski repository'ler opt-in yapana kadar eski formatta kalıyor; geçişi kesintisiz yapmak için önce yeni subject'le ikinci bir credential ekle, OIDC ayarlarından opt-in yap, workflow'u doğrula, sonra eskisini sil.
Nisan 2026'da GA (General Availability, genel kullanıma açılma) olan bir diğer yenilik de repository custom property'lerinin token'a repo_property_ önekiyle claim olarak eklenebilmesi. Organization admin'i örneğin tier property'sini OIDC ayarlarından açarsa, tier=production olan her repository'nin token'ında repo_property_tier claim'i görünür. Trust policy'yi repository adları listesi yerine bu claim'e göre yazmak, yeni bir servis eklendiğinde cloud tarafına dokunmadan erişim verebilmek demek. Azure'da bunu klasik federated credential ile değil, aşağıda değineceğimiz flexible federated credential'ın expression'ıyla kullanabilirsin.
Azure Tarafı: App Registration ve Federated Credential
Azure'da hesabım olmadığı için bu bölümdeki komutlar Microsoft Learn'deki şemaya göre yazıldı, çalıştırılmadı. Bookstore için iki environment, iki kimlik:
# Staging kimliği: yalnızca staging slot'una deploy edebilir
az ad app create --display-name bookstore-deploy-staging
az ad sp create --id "$STAGING_APP_ID"
az ad app federated-credential create --id "$STAGING_APP_OBJECT_ID" --parameters staging.json
az role assignment create --assignee "$STAGING_APP_ID" --role "Website Contributor" \
--scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/rg-bookstore/providers/Microsoft.Web/sites/bookstore-api/slots/staging"
{
"name": "bookstore-environment-staging",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:octo-org/bookstore:environment:staging",
"audiences": ["api://AzureADTokenExchange"]
}
Production için aynısını environment:production subject'iyle ve site seviyesinde rol ataması ile ayrı bir app registration'da yaparsın. İki kimlik ayrı olduğu için staging job'ı ele geçirilse bile production slot'una dokunamaz. GitHub tarafında iki environment'a da aynı isimle AZURE_CLIENT_ID secret'ı koyarsın; değerler farklı. 09'daki "aynı isim, environment'a göre farklı değer" fikri burada güvenlik sınırına dönüşüyor. Client ID, tenant ID ve subscription ID gizli bilgiler değil, ama azure/login README'si bunları yalnızca secrets üzerinden vermeni, PR (pull request) başlığı veya workflow_dispatch input'u gibi dışarıdan gelen değerlerle doldurmamanı öneriyor; v3 client ID'yi log'da varsayılan olarak maskeliyor.
Klasik federated credential'da subject birebir ve büyük/küçük harf duyarlı eşleşir; wildcard yok. Organization adında büyük harf varsa (Octo-Org) subject'i token'daki gibi yazmalısın. Bir uygulamaya en fazla 20 federated credential eklenebiliyor; çok sayıda branch veya repository için Entra'nın preview'daki flexible federated identity credential'ı, claimsMatchingExpression ile pattern eşleştirmeye izin veriyor. GitHub için bu expression'ın sub yanında repository_id ve/veya repository_owner_id claim'lerini de kontrol etmesi zorunlu.
Workflow: Staging Slot'a Deploy, Smoke Test ve Swap
App Service'in deployment slot'ları (Standard ve üstü tier'larda) blue-green deploy'un hazır hâli: staging slot'u production ile aynı App Service planında çalışan ikinci bir uygulama. Yeni sürümü oraya deploy edip test edersin, sonra swap ile iki slot'un yerini değiştirirsin. Blue-green'in mantığı ve geri dönüş stratejisi ilerideki Deployment Stratejileri serisinin blue-green yazısında; burada workflow tarafı:
name: deploy-azure
on:
push:
tags: ['v*']
workflow_dispatch:
permissions:
contents: read
env:
AZURE_WEBAPP_NAME: bookstore-api
AZURE_RESOURCE_GROUP: rg-bookstore
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- run: dotnet publish api/src/Bookstore.Api -c Release -o out/api
- uses: actions/upload-artifact@v7
with:
name: api-publish
path: out/api
deploy-staging:
needs: build
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
environment:
name: staging
url: ${{ steps.deploy.outputs.webapp-url }}
concurrency:
group: deploy-staging
cancel-in-progress: false
steps:
- uses: actions/download-artifact@v8
with:
name: api-publish
path: out/api
- uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- id: deploy
uses: azure/webapps-deploy@v3
with:
app-name: ${{ env.AZURE_WEBAPP_NAME }}
slot-name: staging
package: out/api
- name: Smoke test staging slot
env:
SLOT_URL: ${{ steps.deploy.outputs.webapp-url }}
run: |
for attempt in 1 2 3 4 5; do
if curl --fail --silent --show-error "$SLOT_URL/health"; then
exit 0
fi
echo "Attempt $attempt failed, retrying in 10s"
sleep 10
done
exit 1
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
permissions:
id-token: write
environment:
name: production
url: https://bookstore-api.azurewebsites.net
concurrency:
group: deploy-production
cancel-in-progress: false
steps:
- uses: azure/login@v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Swap staging into production
run: |
az webapp deployment slot swap \
--resource-group "$AZURE_RESOURCE_GROUP" \
--name "$AZURE_WEBAPP_NAME" \
--slot staging \
--target-slot production
Birkaç karar var. Staging'in environment URL'i (Uniform Resource Locator) sabit yazılmadı, webapps-deploy'un webapp-url output'undan geliyor; slot'un gerçek adresi deployment kaydına düşüyor. Smoke test bu URL'i run: içine expression olarak gömmek yerine env: üzerinden alıyor; değer güvenli bir kaynaktan gelse de bu alışkanlık 13. yazıdaki script injection konusunun temeli. Smoke test beş kez, onar saniye arayla deniyor, çünkü yeni deploy edilen bir slot ilk istekte soğuk başlar. Production job'ı yeniden build etmiyor ve dosya kopyalamıyor; yalnızca swap yapıyor. Production'a giden, staging'de test edilmiş byte'ların aynısı.
actionlint üç workflow'u da (deploy-azure, oidc-probe ve 09'dan deploy) temiz geçti. act ile koşunca build ve artifact adımları geçti, azure/login ise OIDC'ye gelmeden düştü:
[deploy-azure/deploy-staging] ⭐ Run Main azure/login@v3
[deploy-azure/deploy-staging] | Running Azure CLI Login.
[deploy-azure/deploy-staging] ❗ ::error::Login failed with Error: Unable to locate executable file: az. Please verify either the file path exists or the file can be found within a directory specified by the PATH environment variable. Also check the file mode to verify the file is executable.. Double check if the 'auth-type' is correct. Refer to https://github.com/Azure/login#readme for more information.
[deploy-azure/deploy-staging] ❌ Failure - Main azure/login@v3 [507.08325ms]
[deploy-azure/deploy-staging] 🏁 Job failed
azure/login kendi başına bir şey yapmıyor, Azure CLI'yi (Command Line Interface) çağırıyor. GitHub'ın ubuntu-latest image'ında az kurulu geliyor; act'in kullandığı catthehacker/ubuntu:act-24.04 image'ında yok. Kursaydık bile bir sonraki engel OIDC olacaktı: act'te token endpoint'i yok. Kısacası bu workflow'un Azure'la konuşan kısmı yerelde doğrulanamıyor; yerelde test edebileceğin şey build, artifact akışı ve YAML (YAML Ain't Markup Language) dosyasının kendisi. Bu yüzden ilk gerçek koşuyu bir test subscription'ında, workflow_dispatch ile yap.
AWS ve Google Cloud Karşılıkları
Akış AWS (Amazon Web Services) ve Google Cloud'da da aynı; değişen, trust policy'nin yazıldığı yer ve action'ın adı:
| Konu | Azure | AWS | Google Cloud |
|---|---|---|---|
| Action | azure/login@v3 | aws-actions/configure-aws-credentials@v6 | google-github-actions/auth@v3 |
| Workflow girdisi | client-id, tenant-id, subscription-id | role-to-assume, aws-region | workload_identity_provider |
| Güven tanımı | App registration veya managed identity üzerinde federated credential | IAM (Identity and Access Management) OIDC identity provider ve role trust policy | Workload Identity Pool ve provider |
| Varsayılan audience | api://AzureADTokenExchange | sts.amazonaws.com | Provider'ın tam kaynak adı |
| Subject kısıtı | Birebir subject veya flexible expression | token.actions.githubusercontent.com:sub koşulu (StringEquals / StringLike) | attribute-condition (CEL, Common Expression Language ifadesi) |
AWS'nin README'sindeki trust policy örneği şimdiden yeni, ID'li subject formatıyla yazılmış. AWS'de StringLike ile repo:octo-org/* gibi geniş bir pattern yazmak mümkün ve tehlikeli; Google Cloud tarafında da README, provider'ı en azından assertion.repository_owner ile kısıtlamanı istiyor. Kural her yerde aynı: koşulu repository, environment ve mümkünse ID'lerle daralt.
Üretimde Dikkat
Credential'ı environment'a bağla, branch'e değil. environment:production subject'i, environment'ın required reviewer ve tag policy'sini Azure erişiminin de ön koşulu yapar. Branch subject'i ise main'e push edebilen her workflow'a production kapısını açar.
Rolü en dar scope'a ver. Subscription seviyesinde Contributor, OIDC'nin getirdiği her şeyi geri alır. Staging kimliği slot'a, production kimliği tek siteye.
Swap'tan önce slot ayarlarını kontrol et. "Deployment slot setting" olarak işaretlenmemiş app setting'ler ve connection string'ler swap'la birlikte production'a geçer; staging veritabanına bakan bir connection string'i production'a taşımak klasik bir kazadır.
Rollback'i planla. Swap'ın güzel tarafı, sorun çıkarsa aynı komutla geri swap yapılabilmesi: eski sürüm artık staging slot'unda bekliyor. Bunu manuel değil, ayrı bir workflow_dispatch ile tek tık hâline getir.
Repository'nin subject formatını bil. 15 Temmuz 2026'dan sonra oluşturulan veya adı değişen her repository ID'li formatta. Repository'yi yeniden adlandırmak da formatı değiştirir; Azure tarafını önceden güncellemeden rename yapma.
Sık Düşülen Tuzaklar
id-token: write'ı workflow seviyesine yazmak. Böylece test job'ları da token isteyebilir. Yalnızca cloud'a giden job'a ver.
Environment ekleyip credential'ı güncellemeyi unutmak. Job'a environment: eklemek sub'ı değiştirir; dün çalışan login bugün AADSTS700213 ile düşer.
"Her branch" kabul eden geniş subject. AWS'de repo:octo-org/bookstore:*, Azure'da ref:refs/heads/* eşleyen bir expression, feature branch'e push edebilen herkesi production'a deploy edebilir hâle getirir.
Portal'da owner/repository ID alanlarını eski bir repository için doldurmak. azure/login README'si bunu ayrıca uyarıyor: portal ID'li subject üretir, opt-in yapmamış repository'nin token'ı ise isimli; eşleşme olmaz. Ters yönü de var: yeni repository'ye isimli subject.
Büyük/küçük harf farkı. Entra subject'i büyük/küçük harf duyarlı karşılaştırır; Octo-Org ile octo-org farklı. Hata mesajı (AADSTS7002138) bunu açıkça söylüyor, ama ilk seferde kimse okumuyor.
Token'ı debug için log'a basmak. Probe'daki gibi yalnızca seçili claim'leri bas; JWT'nin tamamı geçerlilik süresi boyunca bir bearer credential.
Küçük Bir Deneme
★☆☆ oidc-probe'u kendi public test repository'nde iki kez çalıştır: bir kez environment'sız, bir kez environment: staging ile. sub değerlerini karşılaştır ve repository'nin hangi subject formatını kullandığını not al.
★★☆ deploy-production job'ına, swap'tan sonra production URL'inde /health'i kontrol eden ve başarısız olursa az webapp deployment slot swap'ı tersine çalıştıran bir step ekle. if: failure() ile hangi step'lerin koştuğunu act'te gör (Azure adımları düşecek, ama koşul akışı görünür).
★★★ AWS için bir IAM role trust policy'si yaz: yalnızca octo-org/bookstore repository'sinin production environment'ına izin versin, ID'li subject formatını kullansın ve aud'u sts.amazonaws.com ile sınırlasın. Aynı policy'yi StringLike ve * ile yazınca neyin açıldığını tek cümleyle açıkla.
PaaS'a (Platform as a Service) deploy, credential'ı cloud'a devretmenin en temiz yolu. Ama her şey App Service'te çalışmıyor; çoğu ekip hâlâ kendi Linux sunucusuna container deploy ediyor. Bir sonraki yazıda Bookstore'un image'ını GHCR'a (GitHub Container Registry) push'layıp SSH (Secure Shell) ile kendi sunucumuza, known_hosts pin'leyerek deploy edeceğiz.