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.signer enforce 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.signer on 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 -nocrypt

Security 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_ID

This 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_ID

After 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

  1. The Go app creates a KMS-backed signer — it doesn’t hold any key, it just knows how to call Cloud KMS to sign.
  2. ghinstallation builds 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).
  3. The signed JWT is sent to GitHub’s API to get an installation access token (valid for 1 hour).
  4. ghinstallation + go-github automatically refresh the token when it expires — no manual renewal logic needed.
  5. 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.

References