mrkeyoor.com_
Fri 07 Aug 20:53 UTC
npmSecurityupdated 07 Aug 2026

universal-github-app-jwt

universal-github-app-jwt does one thing: it turns a GitHub App ID and a private key into the short-lived signed JWT that GitHub accepts as an app-level bearer token. You call one async function, you get back a token string, the app id you passed, and the expiry timestamp. The word universal is the point. Instead of pulling in a Node-only JWT library, it signs with WebCrypto, which exists in Node, Deno, Bun, browsers, Cloudflare Workers, and other edge runtimes, so the same code path works everywhere without a bundler shim. It has zero runtime dependencies and the whole published bundle is under a kilobyte gzipped. What it does not do is anything after the JWT: exchanging that token for an installation access token, caching it, retrying, or refreshing it is your problem or @octokit/auth-app's. Most people never install it on purpose; it shows up under @octokit/auth-app, which sits under octokit and Probot, and that is where the download count comes from.

Verdict

For the narrow job of signing a GitHub App JWT in a runtime without node:crypto, this is the right size and has no real competition. If you are on Node and need installation tokens anyway, install @octokit/auth-app and get this as a dependency instead of wiring the exchange yourself.

API stability5/5One default export, three options in, three fields out, and the shape has not changed through the 2.x line. The v2 break was dropping CommonJS and moving to WebCrypto, not changing the call signature.
Docs4/5Every option and return field is documented in a table, and the private key format section is unusually good: a table matching PEM header lines to formats plus copy-paste conversion recipes for Node, OpenSSL, and ssh-keygen. Two gaps: it does not mention that Node converts PKCS#1 automatically while other runtimes do not, and its expiration example passes UNIX seconds to new Date(), which yields a 1970 date.
Maintenance4/5Last push 31 July 2026, 4 open issues plus 5 open PRs, releases driven by semantic-release with full test coverage enforced through c8 --100 and a separate Deno test suite. It is one maintainer on a finished 60-line package inside the Octokit orbit, so the bus factor is low but the surface area is tiny.
Ecosystem5/5Around 8.79M weekly downloads, almost all of it as a dependency of @octokit/auth-app, which in turn backs octokit, Probot, and most GitHub Actions that authenticate as an app. Direct usage is rare, which the 33 stars reflect.

Use it if

  • You are authenticating as a GitHub App from an edge runtime such as Cloudflare Workers, Deno Deploy, or Vercel Edge, where node:crypto and jsonwebtoken are not available but WebCrypto is
  • You want app-level API calls only, such as GET /app, GET /app/installations, or creating an installation token by hand, and pulling in the full Octokit auth strategy is more than you need
  • You care about install size in a function that cold-starts often: zero dependencies and roughly 0.9 KB gzipped against jsonwebtoken's dependency tree
  • You need the signing step isolated so you can unit test it with a fixed clock, which the now option exists for
  • You are already inside the Octokit ecosystem and want the same JWT behaviour, including the 30 second backdated iat, that Octokit itself uses
Skip it if

Setup reality

npm install universal-github-app-jwt pulls in nothing else, but the private key is where every hour of debugging goes. GitHub gives you a .pem in PKCS#1 format, which starts with BEGIN RSA PRIVATE KEY, and WebCrypto only imports PKCS#8, which starts with BEGIN PRIVATE KEY. On Node the package quietly converts PKCS#1 for you using node:crypto, so it works on your laptop and then throws '[universal-github-app-jwt] Private Key is in PKCS#1 format, but only PKCS#8 is supported' the moment you deploy to a Worker or Deno, because the non-Node code path is a no-op. Convert ahead of time with openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private-key.pem -out private-key-pkcs8.key and store that instead. If you keep the key in 1Password as an SSH key it will come back in OpenSSH format, which throws in every runtime including Node, and needs ssh-keygen -p -m PKCS8 to fix. Storing the key in an environment variable mangles the line breaks into literal backslash-n, which the package replaces for you, so that particular pain is handled. The package is ESM only with a conditional #crypto import, which some older bundlers resolve to the wrong branch and then complain that node:crypto is missing in a browser build. The id option takes the numeric App ID or the newer Client ID, and GitHub recommends the Client ID for github.com and GHES 3.14 and later. Finally the returned expiration is UNIX seconds, not milliseconds, despite the README example that passes it straight to new Date().

Patterns

Sign an app JWTbasic-usage

import githubAppJwt from 'universal-github-app-jwt'

const {token, appId, expiration} = await githubAppJwt({
  id: process.env.GITHUB_APP_CLIENT_ID,
  privateKey: process.env.GITHUB_APP_PRIVATE_KEY
})

It is a default export, not a named one. The function is async because WebCrypto key import and signing both return promises. expiration is seconds since the epoch, so use new Date(expiration * 1000) if you need a real date.

Use the JWT against app-level endpointscall-the-api

const res = await fetch('https://api.github.com/app', {
  headers: {
    authorization: `bearer ${token}`,
    accept: 'application/vnd.github+json'
  }
})

The scheme is bearer, not token. An app JWT only works on /app endpoints such as /app, /app/installations, and /app/installations/:id/access_tokens. Using it against /repos or /user returns 401 with a message about needing an installation token.

Trade the JWT for an installation tokeninstallation-token

const {token: jwt} = await githubAppJwt({id, privateKey})

const res = await fetch(
  `https://api.github.com/app/installations/${installationId}/access_tokens`,
  {method: 'POST', headers: {authorization: `bearer ${jwt}`}}
)
const {token: installationToken, expires_at} = await res.json()

// now: authorization: `token ${installationToken}`

This is the step the package does not do for you, and the point where most people should switch to @octokit/auth-app: it caches installation tokens, refreshes them before the one hour expiry, and handles the permissions and repository_ids body fields. Note the header prefix changes from bearer to token.

Convert the key GitHub gave you, in codepkcs1-to-pkcs8-node

import crypto from 'node:crypto'

const privateKeyPkcs8 = crypto
  .createPrivateKey(process.env.PRIVATE_KEY)
  .export({type: 'pkcs8', format: 'pem'})

const {token} = await githubAppJwt({id, privateKey: privateKeyPkcs8})

On Node this is what the package already does internally, so it is redundant there. It matters when you convert once at build time or in a Node script and store the PKCS#8 result for a runtime that cannot convert, such as a Cloudflare Worker.

Convert the key ahead of deployingpkcs1-to-pkcs8-openssl

openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt \
  -in private-key.pem -out private-key-pkcs8.key

# OpenSSH key from a password manager:
cp private-key.pem private-key-pkcs8.key
ssh-keygen -p -m PKCS8 -N "" -f private-key-pkcs8.key

Check the first line to know what you have: BEGIN RSA PRIVATE KEY is PKCS#1, BEGIN PRIVATE KEY is PKCS#8, BEGIN OPENSSH PRIVATE KEY is OpenSSH. OpenSSH throws in every runtime including Node, so it fails on your laptop too rather than only in production.

Store the key in an environment variableenv-var-newlines

# .env, all on one line
GITHUB_APP_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEv...\n-----END PRIVATE KEY-----\n"

// no manual .replace() needed
const {token} = await githubAppJwt({
  id: process.env.GITHUB_APP_ID,
  privateKey: process.env.GITHUB_APP_PRIVATE_KEY
})

The package replaces literal backslash-n sequences with real newlines before parsing, so the usual privateKey.replace(/\\n/g, '\n') dance is unnecessary. Base64-encoding the whole PEM into one env var is still the safer option in CI systems that trim or reflow values.

Pick the right identifierclient-id-or-app-id

// preferred on github.com and GHES 3.14+
await githubAppJwt({id: 'Iv23liABCDEFGH1234', privateKey})

// older numeric App ID still works
await githubAppJwt({id: 123456, privateKey})

The id becomes the iss claim, and GitHub accepts either form. TypeScript carries the type through, so passing a string gives you appId typed as string and passing a number gives you number. Client IDs are strings that start with Iv, App IDs are numbers, and mixing them up produces a 401 with no useful detail.

Handle a machine whose clock is aheadclock-skew

// override the clock explicitly
const {token, expiration} = await githubAppJwt({
  id,
  privateKey,
  now: Math.floor(Date.now() / 1000) - 60
})

The package already backdates iat by 30 seconds because GitHub rejects tokens whose iat is in the future. If you still see 'issued at' errors your host clock is off by more than that, and now lets you compensate without touching the system clock. exp is always iat plus 600 seconds, which is GitHub's maximum.

Do not sign a JWT on every requestcache-the-token

let cached = null

async function getAppJwt() {
  const nowSec = Math.floor(Date.now() / 1000)
  if (cached && cached.expiration - 60 > nowSec) return cached.token
  cached = await githubAppJwt({id, privateKey})
  return cached.token
}

Signing is an RSA operation and on a cold edge runtime it is measurable. The token is valid for roughly 9.5 minutes because of the backdated iat, so reuse it with a safety margin rather than treating the full 600 seconds as usable.

Run it in a Cloudflare Workeredge-runtime

export default {
  async fetch(request, env) {
    const {token} = await githubAppJwt({
      id: env.GITHUB_APP_CLIENT_ID,
      privateKey: env.GITHUB_APP_PRIVATE_KEY // must already be PKCS#8
    })
    return new Response(token)
  }
}

This is the case the package exists for: globalThis.crypto.subtle is used instead of node:crypto through a conditional import in package.json. Store the key as a Worker secret in PKCS#8 form, because the Worker branch does no conversion and will throw at signing time.

Distinguish a bad key from a bad apperror-handling

try {
  await githubAppJwt({id, privateKey})
} catch (error) {
  if (String(error.message).includes('PKCS#1')) {
    // key format problem, not a credentials problem
  }
  throw error
}

The two format errors are thrown before any network call and both name the format in the message with a link to the README section. A 401 from GitHub after a successful sign means the id or the key does not match the app, which the library cannot tell you about.

Import it without npmdeno-and-browser

// Deno or a browser module script
import githubAppJwt from 'https://esm.sh/universal-github-app-jwt'

esm.sh serves the types for Deno as well. Signing in a browser means the app private key is in the browser, which hands full app privileges to anyone who opens devtools, so treat the browser support as useful for local tooling and nothing else.

Alternatives

PackageRegistryPick it when
@octokit/auth-appnpmYou need installation access tokens, OAuth user tokens, caching, and automatic refresh rather than just the app-level JWT
josenpmYou are signing or verifying JWTs generally across runtimes and need control over algorithm, claims, and key handling
jsonwebtokennpmYou are Node-only, already have PKCS#1 keys, and want the long-standing sign and verify API with arbitrary claims
@octokit/appnpmYou want the whole GitHub App surface: webhooks, per-installation Octokit instances, and OAuth, not a token function