Authenticating to external Terraform modules across multiple GitHub organizations
Last updated: August 5, 2026
Overview
When Spacelift runs terraform init, it needs credentials to download any Terraform modules hosted in private GitHub repositories. The built-in GitHub integration token is not available during terraform init, so some form of credential must be explicitly provided at that stage. This article explains the recommended approach for enterprises with modules spread across multiple GitHub organizations.
Important: There is currently no built-in auto-discovery for Terraform modules across multiple GitHub organizations. Some credential must be present at init time — a before_init hook is unavoidable given how terraform init works.
Recommended solution: GitHub App with a shared Spacelift context
The cleanest approach for multi-org setups is to create a single GitHub App installed across all your organizations. This avoids per-org SSH keys, Personal Access Tokens (PATs), or per-repo registration. The App mints short-lived tokens (~9 minutes) at init time, so there are no long-lived credentials to rotate.
Setup steps
Create a GitHub App with read access to the module repositories, and install it on each of your GitHub organizations.
Collect the following values for each org:
App ID
App private key (PEM format)
Installation ID per org (find these via
GET https://api.github.com/app/installationsusing a JWT, or from the GitHub App settings URL when viewing the installation)
Create a Spacelift context and store the following as secret environment variables:
GH_APP_ID— your GitHub App's IDGH_APP_PRIVATE_KEY_PEM— your GitHub App's private key in PEM format
Label the context
autoattach:*so it is automatically attached to all stacks in your organization. See the Spacelift context documentation for more details.Add the following script as a
before_inithook on the context, editing theORG_INSTALLATIONSmap to list each GitHub org and its corresponding installation ID:
set -euo pipefail
##############################################################################
# EDIT here — map each GitHub org to its App installation ID (one per line).
# Find installation IDs with the App JWT:
# GET https://api.github.com/app/installations (id field per org)
# or GitHub App settings > Install App > click the org > the number in the URL.
##############################################################################
ORG_INSTALLATIONS="
your-first-org=11111111
your-second-org=22222222
your-third-org=33333333
"
##############################################################################
# EDIT here — ONLY if you're on GitHub Enterprise Server (self-hosted GitHub).
# If your modules live on github.com, leave these two lines exactly as-is.
##############################################################################
GH_HOST="github.com"
GH_API="https://api.github.com"
# GHES example:
# GH_HOST="github.yourcompany.com"
# GH_API="https://github.yourcompany.com/api/v3"
##############################################################################
# DO NOT EDIT BELOW.
# GH_APP_ID and GH_APP_PRIVATE_KEY_PEM are supplied by the Spacelift context
# as secret env vars. Token TTL is 9 min — GitHub's max.
##############################################################################
: "${GH_APP_ID:?set GH_APP_ID as a secret env var in the context}"
: "${GH_APP_PRIVATE_KEY_PEM:?set GH_APP_PRIVATE_KEY_PEM as a secret env var in the context}"
b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
# 1) Build a JWT signed with the App private key
now=$(date +%s); iat=$((now - 60)); exp=$((now + 540))
header=$(printf '{"alg":"RS256","typ":"JWT"}' | b64url)
payload=$(jq -nc --argjson iat "$iat" --argjson exp "$exp" --arg iss "$GH_APP_ID" \
'{iat:$iat, exp:$exp, iss:$iss}' | b64url)
unsigned="${header}.${payload}"
sig=$(printf '%s' "$unsigned" \
| openssl dgst -binary -sha256 -sign <(printf '%s' "$GH_APP_PRIVATE_KEY_PEM") | b64url)
jwt="${unsigned}.${sig}"
# 2) Per org: exchange JWT for a short-lived installation token + add a git rewrite
while IFS='=' read -r org inst_id; do
org="$(echo "$org" | xargs)"; inst_id="$(echo "$inst_id" | xargs)"
[ -z "$org" ] && continue
token=$(curl -sS -X POST \
-H "Authorization: Bearer ${jwt}" \
-H "Accept: application/vnd.github+json" \
"${GH_API}/app/installations/${inst_id}/access_tokens" | jq -r .token)
if [ -z "$token" ] || [ "$token" = "null" ]; then
echo "ERROR: could not mint token for '${org}' (installation ${inst_id})"; exit 1
fi
git config --global \
url."https://x-access-token:${token}@${GH_HOST}/${org}/".insteadOf \
"https://${GH_HOST}/${org}/"
echo "configured module auth for ${org}"
done <<< "$ORG_INSTALLATIONS"
How it works
At runtime, the hook:
Generates a short-lived JWT using your GitHub App credentials.
Exchanges the JWT for a short-lived installation access token (valid ~9 minutes) for each configured GitHub org.
Configures
gitto rewrite HTTPS URLs for each org to include the token, soterraform initcan download modules without any changes to your modulesourcereferences.
Alternative: SSH key approach
If you prefer SSH, you can mount a private key as id_ed25519 and add a before_init hook to trust GitHub's host key:
ssh-keyscan -t rsa github.com >> ~/.ssh/known_hostsSee the Spacelift documentation on using SSH for external modules for full details. However, this approach requires managing SSH keys per organization and is generally more complex in multi-org setups.
Alternative: Register repos as Spacelift modules
Another option is to register each module repository directly in Spacelift using a .spacelift/config.yml file. This reuses your existing GitHub integration with no SSH key required. However, each repository must be registered individually — there is no automatic discovery across multiple GitHub organizations. See the Spacelift Terraform module documentation for details.