Key Management Service (KMS) and how to use Google Cloud KMS to secure a GitHub App private key.
What is KMS?
KMS is a managed service (offered by AWS, GCP, Azure, etc.) that centralizes the storage, management, and usage of cryptographic keys. The fundamental principle: your application never sees the raw key material. All crypto operations (encrypt, decrypt, sign, verify) happen inside the KMS boundary, often backed by Hardware Security Modules (HSMs).
Core capabilities
- Key generation & import — Create keys inside KMS, or import your own (BYOK). Either way, once inside, the plaintext key never leaves.
- Encrypt/Decrypt — Your app sends plaintext to KMS, KMS encrypts with the stored key, returns ciphertext (and vice versa).
- Asymmetric signing — KMS signs a payload (e.g. a JWT) with a private key. You only receive the signature, never the key.
- Key rotation — KMS can automatically rotate keys on a schedule without changing your app code.
- IAM & audit — Fine-grained access control (who can use which key for which operation) and full audit logging of every cryptographic operation.
GitHub App + Google Cloud KMS
A practical, end-to-end example of using KMS to secure a GitHub App private key so it never touches your application runtime.
Why do this?
- Hardware isolation & audit logging — KMS keeps the private key material out of your application runtime and emits Cloud Audit Logs for every cryptographic operation.
- Central access control — IAM policies like
roles/cloudkms.signerenforce least-privilege. Only the designated service account can sign. - Compliance flexibility — Key import preserves ownership of existing RSA keys while meeting regional or BYOK requirements.
Prerequisites
- A GitHub App with its PEM-encoded private key downloaded (Settings → Certificates & secrets)
- A GCP project configured (
gcloud config set project <PROJECT_ID>) - A service account granted
roles/cloudkms.signeron the CryptoKey - Tools: OpenSSL, gcloud, Terraform, Go
Step 1: Convert the key format
GitHub gives you a PEM file. KMS import requires PKCS#8 DER format:
openssl pkcs8 -topk8 -inform PEM -outform DER \
-in key.pem -out key.pkcs8 -nocryptSecurity note: Keep key.pkcs8 in a secure location and delete it after import.
Step 2a: Create a Key Ring (Terraform)
resource "google_kms_key_ring" "github_keyring" {
name = "github-keyring"
location = "global"
}Step 2b: Create an Import Job
gcloud kms import-jobs create import-key \
--location=global \
--keyring=github-keyring \
--import-method=rsa-oaep-4096-sha256-aes-256 \
--protection-level=software \
--project=PROJECT_IDThis uses RSA-OAEP-4096 with AES-256 wrapping — it generates an ephemeral public key that wraps your key material for a one-time secure transfer into KMS.
Step 3: Create the CryptoKey (Terraform)
resource "google_kms_crypto_key" "github_key" {
name = "github-key"
key_ring = google_kms_key_ring.github_keyring.id
purpose = "ASYMMETRIC_SIGN"
import_only = true
skip_initial_version_creation = true
version_template {
algorithm = "RSA_SIGN_PKCS1_2048_SHA256"
protection_level = "SOFTWARE"
}
}Key points: purpose = "ASYMMETRIC_SIGN" (signing only, not encryption), import_only = true (key material comes from you, not generated by KMS).
Step 4: Import the private key
gcloud kms keys versions import \
--location=global \
--keyring=github-keyring \
--key=github-key \
--import-job=import-key \
--algorithm=rsa-sign-pkcs1-2048-sha256 \
--target-key-file=key.pkcs8 \
--project=PROJECT_IDAfter this, you have a Primary CryptoKeyVersion ready for signing. The original key.pkcs8 file should be deleted.
Step 5: Grant IAM permissions (Terraform)
resource "google_kms_crypto_key_iam_member" "github_key_iam" {
crypto_key_id = google_kms_crypto_key.github_key.id
role = "roles/cloudkms.signer"
member = "serviceAccount:svc-github-app@PROJECT_ID.iam.gserviceaccount.com"
}roles/cloudkms.signer is least-privilege — it only allows asymmetric signing, nothing else.
Step 6: Go implementation
The app uses KMS to sign JWTs at runtime, which are exchanged for GitHub installation tokens.
Example Go implementation using Cloud KMS client, ghinstallation for GitHub App auth, and go-github for API calls:
package main
import (
"context"
"fmt"
"log"
"net/http"
"time"
kms "cloud.google.com/go/kms/apiv1"
"github.com/bradleyfalzon/ghinstallation/v2"
"github.com/google/go-github/v72/github"
"github.com/kelseyhightower/envconfig"
"github.com/octo-sts/app/pkg/gcpkms"
)
type Env struct {
GitHubAppID int64 `env:"GITHUB_APP_ID"`
GitHubAppInstallationID int64 `env:"GITHUB_APP_INSTALLATION_ID"`
GitHubAppKMSKeyPath string `env:"GITHUB_APP_KMS_KEY_PATH"`
}
func main() {
ctx := context.Background()
var env Env
if err := envconfig.Process("", &env); err != nil {
log.Fatalf("failed to process env var: %v", err)
}
// 1. Create KMS client
kmsClient, err := kms.NewKeyManagementClient(ctx)
if err != nil {
log.Fatalf("failed to create kms client: %v", err)
}
// 2. Create a signer backed by Cloud KMS
signer, err := gcpkms.New(ctx, kmsClient, env.GitHubAppKMSKeyPath)
if err != nil {
log.Fatalf("failed to create signer: %v", err)
}
// 3. Build an Apps transport that signs JWTs via KMS
atr, err := ghinstallation.NewAppsTransportWithOptions(
http.DefaultTransport, env.GitHubAppID,
ghinstallation.WithSigner(signer),
)
if err != nil {
log.Fatalf("failed to create gh installation transport: %v", err)
}
// 4. Convert to installation-level transport (auto-refreshes tokens)
itr := ghinstallation.NewFromAppsTransport(atr, env.GitHubAppInstallationID)
// 5. Create GitHub client and use the API
client := github.NewClient(&http.Client{Transport: itr, Timeout: 5 * time.Second})
readme, _, err := client.Repositories.GetReadme(ctx, "organization", "repository", nil)
if err != nil {
log.Fatalf("failed to get readme: %v", err)
}
fmt.Println(readme.GetContent())
}How the runtime flow works
- The Go app creates a KMS-backed signer — it doesn’t hold any key, it just knows how to call Cloud KMS to sign.
ghinstallationbuilds a JWT with standard GitHub App claims (iss, iat, exp) and calls the signer to sign it via KMS (the private key never enters app memory).- The signed JWT is sent to GitHub’s API to get an installation access token (valid for 1 hour).
ghinstallation+go-githubautomatically refresh the token when it expires — no manual renewal logic needed.- The GitHub client uses this token transparently for all API calls.
The big picture
Without KMS: your private key sits as a file or env var in your runtime. If the app is compromised, the key is stolen.
With KMS: the private key only exists inside KMS. Your app says “sign this for me” and gets back a signature. Even if the app is fully compromised, the attacker can only make signing requests (which are rate-limited and audit-logged) — they can never exfiltrate the key itself.