Skip to content

feat: KMS-backed JWT signing for GitHub App authentication (private key never leaves HSM) #5134

Description

@vegardx

Problem

The GitHub App private key is currently stored in SSM Parameter Store (encrypted at rest) and loaded into Lambda memory for every JWT signing operation. This means:

  1. The plaintext private key is in Lambda memory during execution — any memory dump, debug log, or compromised dependency could exfiltrate it
  2. No hardware security boundary — the key exists as a software secret, not in an HSM
  3. Single App limitation — the module is hardwired to one App's credentials via global env vars, making multi-App deployments impossible without separate infrastructure stacks

Proposal

Move JWT signing to AWS KMS using an asymmetric RSA key (RSA_2048, SIGN_VERIFY, RSASSA_PKCS1_V1_5_SHA_256). The private key never leaves the HSM — Lambda calls kms:Sign and receives only the signature bytes.

Why this is a clean change

The codebase already has the perfect abstraction point. @octokit/auth-app accepts a custom createJwt callback (mutually exclusive with privateKey), and this repo already uses it:

// auth.ts — current implementation
const createJwt = async (appId, timeDifference?) => {
  const jwt = signJwt({ iat, exp, iss: appId, jti: randomUUID() }, privateKey);
  return { jwt, expiresAt: ... };
};
return createAppAuth({ appId, createJwt });

The change is swapping what happens inside signJwt:

// Before: node:crypto with in-memory key
const signature = createSign('RSA-SHA256').update(message).sign(privateKey, 'base64url');

// After: KMS Sign API (key never exposed)
const result = await kmsClient.send(new SignCommand({
  KeyId: kmsKeyArn,
  Message: Buffer.from(message),
  MessageType: 'RAW',
  SigningAlgorithm: 'RSASSA_PKCS1_V1_5_SHA_256',
}));
const signature = Buffer.from(result.Signature!).toString('base64url');

Design

// New: jwt-signer.ts
export interface JwtSigner {
  sign(message: string): Promise<string>; // returns base64url signature
}

export class LocalJwtSigner implements JwtSigner { /* existing node:crypto path */ }
export class KmsJwtSigner implements JwtSigner { /* KMS Sign API */ }

Guard: env var GITHUB_APP_KMS_KEY_ARN — if set, use KMS signer. If unset, fall back to SSM PEM (100% backwards compatible).

Multi-App foundation

This architecture naturally extends to multiple GitHub Apps:

  1. All Apps share the same webhook secret → single webhook Lambda verifies all payloads
  2. Webhook payload carries installation.id → already passed through to scale-up Lambda
  3. Scale-up resolves credentials by installation_id → each App has its own KMS key
  4. Token cache (PR feat(lambda): cross-Lambda installation token cache via DynamoDB #5132) is already keyed by installation_id → each App's tokens cached independently

The initial PR implements single-App KMS signing. Multi-App credential resolution (DynamoDB lookup by installation_id) is a follow-up.

Key import constraint

GitHub generates the RSA key pair when you create an App. The private key must be imported into KMS (EXTERNAL key material origin). This is a one-time setup operation.

Terraform: aws_kms_external_key resource handles the lifecycle.

KMS rate limits

Default KMS quota: 300 TPS for RSA signing (region-dependent). Combined with the token cache (#5132, ~1 JWT sign/hour/installation), this is never a bottleneck.

Terraform changes

  • Optional aws_kms_external_key resource (gated by variable)
  • IAM policy: kms:Sign scoped to the key
  • Lambda env var: GITHUB_APP_KMS_KEY_ARN
  • Documentation on importing the GitHub App PEM into KMS

Benefits

Aspect SSM (current) KMS (proposed)
Key at rest Encrypted in SSM HSM-backed, never extractable
Key in memory Plaintext in Lambda during execution Never in Lambda memory
Audit trail SSM GetParameter logs KMS Sign logs (CloudTrail)
Key rotation Manual SSM param swap KMS key material re-import
Multi-App Not supported Natural extension via per-App keys

Related


I will provide a PR for this.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions