Back

CICD-SEC-2: Inadequate Identity and Access Management - Code Examples

Each pair below shows an insecure identity/access configuration and the secure version in the same platform. The examples focus on the failures that dominate real findings: over-permissioned tokens, long-lived static credentials, local accounts that bypass SSO, and standing broad access.

GitHub Actions

Insecure

# .github/workflows/deploy.yml
permissions: write-all               # every job gets write to everything

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # Long-lived static cloud keys stored as secrets, no expiry, no rotation
      - name: Deploy
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        run: ./deploy.sh
      # A broad, non-expiring PAT reused for cross-repo access
      - run: gh repo clone my-org/another-repo
        env:
          GH_TOKEN: ${{ secrets.ORG_WIDE_PAT }}   # scopes: repo, admin:org

Secure

# .github/workflows/deploy.yml
permissions:
  contents: read                     # workflow-wide least-privilege floor

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write                # mint a short-lived OIDC token
    steps:
      - uses: actions/checkout@v4
      # No stored cloud keys: federate to a scoped, short-lived role
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/ci-deploy
          aws-region: us-east-1
      - run: ./deploy.sh
      # For cross-repo access, use a fine-grained, expiring token scoped
      # to the ONE repo needed - not an org-wide PAT.

GitLab CI

Insecure

# .gitlab-ci.yml
deploy:
  stage: deploy
  script:
    # A group-owner PAT with no expiry, pasted into a CI variable,
    # scoped far beyond this project.
    - curl -H "PRIVATE-TOKEN: $GROUP_OWNER_PAT" "$CI_API_V4_URL/projects"
    - ./deploy.sh
  # Uses a shared "deploy" account's credentials for everything downstream.

Secure

# .gitlab-ci.yml
deploy:
  stage: deploy
  # Prefer the built-in CI_JOB_TOKEN (scoped to this pipeline) or a
  # project access token with a minimal role and a short expiry.
  id_tokens:
    CLOUD_TOKEN:
      aud: https://cloud.example.com   # OIDC audience for federation
  script:
    - ./federate-and-deploy.sh          # exchanges the OIDC token for
                                        # short-lived, scoped cloud creds
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

# Project Access Token (if a static token is unavoidable):
#   role: Reporter          # least privilege, not Owner/Maintainer
#   scopes: [read_repository]
#   expires_at: 2026-02-01  # never "no expiration"

Jenkins

Insecure

// A local "admin" account outside the IdP, shared over chat, no MFA.
// Anonymous read enabled and anyone can self-register.
jenkins:
  securityRealm: local          // local users, bypasses central SSO
  authorizationStrategy: loggedInUsersCanDoAnything
  allowAnonymousRead: true
  allowSignup: true

// Pipeline uses one global "deploy-bot" credential with admin cloud keys.
withCredentials([string(credentialsId: 'deploy-bot-aws-admin',
                        variable: 'AWS_ADMIN_KEY')]) {
    sh './deploy.sh'
}

Secure

// Delegate authentication to the central IdP; no local logins, no signup.
jenkins:
  securityRealm: oidc                    // SSO via central IdP, MFA enforced
  authorizationStrategy: projectMatrix   // explicit least-privilege grants
  allowAnonymousRead: false
  allowSignup: false

// Per-job, least-privilege, short-lived credentials - no shared admin bot.
// Prefer an OIDC/plugin-based exchange for temporary cloud credentials
// scoped to this job; if a stored credential is unavoidable, scope it to
// one task and rotate it on a schedule.
withCredentials([/* scoped, rotated, job-specific credential */]) {
    sh './deploy.sh'
}
// Break-glass local admin (if any): single account, hardware-key MFA,
// alerted on every use, excluded from normal pipelines.

IdP / SSO and Cloud Federation

Insecure

# Long-lived IAM user with a static access key and admin policy,
# handed to the pipeline and never rotated.
{
  "UserName": "ci-deploy-user",
  "AttachedPolicies": ["AdministratorAccess"],   # far too broad
  "AccessKeys": [{ "Status": "Active", "Age": "540 days" }]  # never rotated
}

# SSO exists, but this service is configured with local auth only,
# so it skips MFA and central logging entirely.

Secure

# No IAM user, no static key: a role assumed via OIDC web identity,
# pinned to the exact repo/branch and granted only what deploy needs.
{
  "RoleName": "ci-deploy",
  "AssumeRolePolicy": {
    "Effect": "Allow",
    "Principal": { "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com" },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
      }
    }
  },
  "AttachedPolicies": ["ci-deploy-minimal"],   # least privilege, not admin
  "AccessKeys": []                              # none to leak or rotate
}

# Every system (SCM, CI, registry, cloud console) authenticates through
# the central IdP with MFA enforced; no local-only services.

What Changed, and Why

FailureInsecureSecure
Token scopewrite-all / org-wide PAT / group-owner tokenLeast-privilege, per-repo, per-job scope
Credential lifetimeStatic keys, non-expiring PATs, never rotatedShort-lived OIDC identities; expiry + rotation where static is unavoidable
Authentication pathLocal accounts bypass SSO/MFA, self-registration onCentral IdP + MFA everywhere, no signup, break-glass only
Shared identityOne deploy-bot/admin account reused everywherePer-workload machine identities, attributable and revocable
Cloud accessPipeline holds AdministratorAccess keysScoped role assumed via federation, pinned to repo/branch

Next Steps