Code Scanning with CodeQL

CodeQL is GitHub’s semantic code analysis engine. Instead of matching text patterns like a linter, it treats your source code as a queryable database and runs security-focused queries against it to find real vulnerabilities: SQL injection, path traversal, hardcoded credentials, unsafe deserialization, and dozens of other classes of bug. When CodeQL runs inside GitHub Actions, results show up as annotated alerts in the repository’s Security tab and as inline review comments on pull requests, without you having to run anything locally.

This lesson assumes you already know how to write a basic workflow file and trigger it on push and pull_request events. Here we focus specifically on the CodeQL workflow: how the analysis pipeline works, how to configure it for one or many languages, how to scope it safely for untrusted contributions, and how to keep the signal-to-noise ratio high enough that people actually act on the alerts.

Overview / How it works

A CodeQL analysis has three stages, each mapped to a step in the workflow:

  • Init — the github/codeql-action/init action downloads the CodeQL CLI and query packs for the languages you specify and prepares an empty database.
  • Build — for compiled languages (Java, C/C++, C#, Go, Swift), CodeQL needs to observe a real compilation to build its database. Interpreted languages (JavaScript/TypeScript, Python, Ruby) don’t need a build step. This is controlled by build-mode.
  • Analyze — the github/codeql-action/analyze action runs the query suite against the finished database and uploads the results as a SARIF (Static Analysis Results Interchange Format) file to GitHub’s code scanning API.

Once uploaded, GitHub renders the SARIF as alerts. A alert has a rule ID, a severity, a file and line range, and often a full data-flow path showing how untrusted input reaches a dangerous operation. Alerts persist across runs: if the same issue is found again, it’s the same alert; if a fix removes it, GitHub marks it fixed automatically.

Syntax or workflow structure

A minimal advanced-setup CodeQL workflow needs a trigger, a permissions block, and a job with three steps: checkout, init, analyze. The permissions block matters more here than in most workflows, because uploading SARIF requires an elevated scope that the default GITHUB_TOKEN does not always have.

Permission Why it’s needed
contents: read Check out the repository to build the CodeQL database
security-events: write Upload SARIF results and create/update code scanning alerts
actions: read Required on private repositories so the analyze step can read workflow run details

Set permissions explicitly at the job level even if your organization already grants broader default permissions — an explicit block documents intent and survives an org-wide tightening of defaults without silently breaking the scan.

Examples

Example 1: single-language advanced setup. This is the workflow GitHub generates for a JavaScript/TypeScript repository, trimmed to the essentials.

name: CodeQL

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]
  schedule:
    - cron: '30 3 * * 1'

permissions:
  contents: read
  security-events: write
  actions: read

jobs:
  analyze:
    name: Analyze (javascript-typescript)
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: read
      security-events: write
      actions: read
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Initialize CodeQL
        uses: github/codeql-action/init@v3
        with:
          languages: javascript-typescript
          build-mode: none

      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@v3
        with:
          category: "/language:javascript-typescript"

Expected behavior: on every push to main and every pull request targeting main, a check named “CodeQL” (or “Analyze (javascript-typescript)”) runs and reports pass/fail. The weekly cron catches issues introduced by new CodeQL query releases even when the code itself hasn’t changed. Alerts appear under Security → Code scanning alerts, and new alerts on a pull request also show as review annotations on the changed lines.

Example 2: multiple languages with different build modes. Most real repositories mix languages. Use a matrix so each language gets its own database and its own category in the Security tab.

name: CodeQL Matrix

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]
  schedule:
    - cron: '15 4 * * 3'

permissions:
  contents: read
  security-events: write
  actions: read

jobs:
  analyze:
    name: Analyze (${{ matrix.language }})
    runs-on: ubuntu-latest
    timeout-minutes: 45
    permissions:
      contents: read
      security-events: write
      actions: read
    strategy:
      fail-fast: false
      matrix:
        include:
          - language: javascript-typescript
            build-mode: none
          - language: python
            build-mode: none
          - language: java-kotlin
            build-mode: autobuild
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Initialize CodeQL
        uses: github/codeql-action/init@v3
        with:
          languages: ${{ matrix.language }}
          build-mode: ${{ matrix.build-mode }}

      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@v3
        with:
          category: "/language:${{ matrix.language }}"

Expected behavior: three parallel jobs run, one per language. build-mode: none skips compilation for the interpreted languages; build-mode: autobuild lets CodeQL attempt to detect and run the Java/Kotlin build itself (Gradle or Maven). If autobuild can’t figure out your build, switch that entry to build-mode: manual and add your own build commands as a step between init and analyze.

build-mode Use when
none Interpreted languages (JS/TS, Python, Ruby) — no compilation exists
autobuild Compiled languages with a standard, single build tool CodeQL can detect
manual Compiled languages with custom, multi-step, or containerized build processes

Example 3: a custom query suite and noise reduction. The default query suite is deliberately conservative to minimize false positives. Once a team has burned down its backlog, broadening the suite and excluding known-noisy paths keeps the signal useful.

# .github/codeql/codeql-config.yml
name: "Custom CodeQL config"

queries:
  - uses: security-extended
  - uses: security-and-quality

paths-ignore:
  - "**/*.test.js"
  - "**/vendor/**"
  - "**/dist/**"

paths:
  - "src"
      - name: Initialize CodeQL
        uses: github/codeql-action/init@v3
        with:
          languages: javascript-typescript
          build-mode: none
          config-file: ./.github/codeql/codeql-config.yml

Expected behavior: the scan now includes the broader security-extended and security-and-quality query packs (more coverage, more scan time, and a few more low-confidence findings to triage), while skipping test fixtures and vendored/build output that would otherwise generate irrelevant alerts.

Step by step

  1. Confirm code scanning is available (public repos get it free; private repos need GitHub Advanced Security enabled).
  2. Add a workflow under .github/workflows/codeql.yml with an explicit permissions block containing contents: read and security-events: write.
  3. List the languages actually present in the repository — scanning a language with no source files wastes runner time and adds an empty category to the Security tab.
  4. Choose a build-mode per compiled language; start with autobuild and fall back to manual only if the build step fails.
  5. Trigger on push to your default branch, on pull_request, and on a weekly schedule so query-pack updates are picked up even during quiet periods.
  6. Run the workflow, open the Security → Code scanning alerts tab, and triage: fix true positives, dismiss false positives with a documented reason (not silently).
  7. Once the backlog is under control, add a codeql-config.yml to widen the query suite and exclude generated or vendored paths.
  8. In branch protection rules, require the CodeQL check to pass, and optionally require code scanning results at a chosen severity threshold before merge is allowed.

Common Mistakes

Mistake 1: omitting security-events: write. If your organization or repository has tightened default workflow permissions to read-only, the analyze step fails with a permissions error when it tries to upload SARIF, even though the scan itself completed successfully.

# Wrong — no explicit permissions block, relies on org default which may be read-only
jobs:
  analyze:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: github/codeql-action/init@v3
        with:
          languages: javascript-typescript
      - uses: github/codeql-action/analyze@v3
# Correct — permissions declared explicitly at job level
jobs:
  analyze:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
      actions: read
    steps:
      - uses: actions/checkout@v4
      - uses: github/codeql-action/init@v3
        with:
          languages: javascript-typescript
      - uses: github/codeql-action/analyze@v3

Mistake 2: analyzing fork pull requests with pull_request_target. pull_request_target runs with the base repository’s permissions and secrets, not the fork’s, and it does not automatically avoid checking out the pull request’s head commit. If a CodeQL workflow triggers on pull_request_target and then checks out and builds github.event.pull_request.head.sha, an external contributor’s build scripts execute with access to your repository’s secrets and a token that may have write permissions — a direct path to secret exfiltration or a poisoned build.

# Risky — attacker-controlled PR code runs with base-repo privileges and secrets
on:
  pull_request_target:
jobs:
  analyze:
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - uses: github/codeql-action/init@v3
        with:
          languages: javascript-typescript
      - uses: github/codeql-action/analyze@v3
# Correct — trigger on pull_request instead; forked-PR runs get a read-only
# token and no repository secrets by default, so it is safe to build and scan
# attacker-controlled code automatically
on:
  pull_request:
    branches: [ "main" ]
jobs:
  analyze:
    permissions:
      contents: read
      security-events: write
      actions: read
    steps:
      - uses: actions/checkout@v4
      - uses: github/codeql-action/init@v3
        with:
          languages: javascript-typescript
      - uses: github/codeql-action/analyze@v3

If you need pull_request_target for something else in the same repository (for example, to post a comment using a token with write access), keep that job separate from the CodeQL build-and-analyze job, and never check out or execute the pull request’s head commit inside a pull_request_target job.

Best Practices

  • Scope permissions per job. Even inside a workflow that has broader top-level permissions, repeat the minimal set at the job level for the CodeQL job so it’s self-documenting and resilient to future changes elsewhere in the file.
  • Pin action versions deliberately. github/codeql-action@v3 tracks a maintained major version and receives query-pack updates automatically, which is usually what you want for a security scanner. Pinning to a commit SHA instead gives you full reproducibility and immunity to a compromised tag, at the cost of manually updating to get new vulnerability queries — a real trade-off, not a default “always pin” rule.
  • Scan only the languages you have. Extra matrix entries cost runner minutes and add empty or near-empty categories to the Security tab that dilute attention from real findings.
  • Start report-only, then gate. Turn on scanning and let the team triage the existing backlog before making the check required in branch protection. Requiring a check that already has 200 unresolved alerts trains people to ignore it.
  • Exclude noise, not risk. Use paths-ignore for vendored dependencies, generated code, and test fixtures — never for application source just because it’s inconvenient to fix.
  • Triage with reasons. Dismissing an alert without a reason (false positive, won’t fix, used in tests) loses the context the next person needs when the same pattern reappears elsewhere.
  • Trust the built-in fork protections. The pull_request trigger already gives forked-PR runs a read-only token and withholds secrets — that’s precisely what makes it safe to run CodeQL automatically on external contributions. Don’t “fix” a perceived permissions problem by switching to pull_request_target.

Practice Exercises

  1. Add an advanced-setup CodeQL workflow to a JavaScript or Python repository with an explicit permissions block, a push/pull_request/weekly schedule trigger, and confirm a check run appears on your next pull request.
  2. Extend the workflow to a matrix covering a second language present in the repository, choosing the correct build-mode for each, and confirm both languages appear as separate categories under Security → Code scanning alerts.
  3. Add a codeql-config.yml that excludes your test or vendor directories and enables the security-and-quality query pack, then compare the alert count before and after.
  4. In branch protection settings, require the CodeQL check to pass before merging, and open a pull request that intentionally introduces an unsafe pattern (such as string-concatenated SQL) to confirm the alert blocks the merge.

Summary

CodeQL turns your source code into a queryable database and runs targeted security queries against it inside a normal GitHub Actions workflow: init, build (if compiled), analyze. Get the permissions block right — contents: read, security-events: write, and actions: read on private repos — or SARIF upload silently fails. Match build-mode to each language, scan on push, pull request, and a weekly schedule, and keep pull request scanning on the safe pull_request trigger rather than pull_request_target, since forked contributions must never run with your repository’s secrets or write-level token. Once the alert backlog is triaged, tighten the query suite, exclude generated paths, and require the check in branch protection so CodeQL becomes a real merge gate instead of a tab nobody opens.