This article is co-authored with a generative AI. Facts have been cross-checked against official documentation where possible, but errors may remain. Please verify against primary sources before making any important decisions.

For an organization that runs several database websites, I built an admin console where the people responsible for data updates—mostly researchers and staff, not engineers—can trigger rebuilds and re-indexes themselves. They don’t have GitHub accounts and don’t have access to Vercel.

This post focuses on the design, especially why I picked a GitHub App over a Personal Access Token (PAT) for the GitHub auth, and how putting Cloudflare Access (Zero Trust) in front of the app removed the need to write any auth code in the app itself.

Architecture

[Operator]
  ↓ Email OTP (Cloudflare Access / Zero Trust)
[admin.example.com]   ← Next.js on Cloudflare Pages
  ↓ GitHub App (installation token)
[GitHub Actions: workflow_dispatch]
[Each repo's admin.yml runs
   npm run es:sync etc.]
  • Hosting: Cloudflare Pages (@opennextjs/cloudflare)
  • Authentication: Cloudflare Access (email one-time PIN)
  • Authorization (vs GitHub): GitHub App installation token
  • Execution: GitHub Actions workflow_dispatch

Each target site already has scripts like npm run es:sync defined in its own repo. The console just calls those workflows—no changes to existing scripts.

The three GitHub auth options, side by side

There are three main ways for code to authenticate against GitHub. Here’s how they compare for the use case “a server triggers workflow_dispatch against a few repos in our org”:

Personal Access Token (PAT)OAuth AppGitHub App
Owned byIndividual userApp (personal or org)App (org-owned)
Acts asThe token holder (a user)What the end-user authorizedThe repos the app is installed on
Token lifetimeUp to 1 year, manual rotation / or no expiryNo expiry by default (8 hours + refresh is opt-in)1 hour, auto-refreshed
When someone leavesTheir tokens die with themRevoke per-userNo effect
Permission granularityFunction × repoOAuth scopeFunction × repo
Audit logsLimitedYesYes (action attributable to the app)
Typical usePersonal scripts, CI secrets“Connect your GitHub” SaaSA server that operates org repos long-term

When to use a PAT

PATs are tied to an individual account and are the easiest thing to issue.

  • Quick scripts on your own terminal
  • Short-lived prototypes
  • Personal-project CI

Think of a PAT as a stand-in for your own terminal. It is generally a poor fit for org infrastructure: when the owner leaves the org, every system using their PAT goes down with them.

GitHub also has a “Fine-grained personal access tokens” page in org settings. That page lets org admins review and approve user PATs that target the org—it is not a place where the org issues PATs. PATs by design are always tied to an individual user.

When to use an OAuth App

An OAuth App fits “the user signs into GitHub in a browser and grants the app permission to act on their behalf.”

  • Services where each user connects their own GitHub (CI/CD vendors, code review tools)
  • When you want per-user scope isolation

Because end-user consent is the model, OAuth Apps are not a good fit for “a server quietly does things in the background”—it becomes ambiguous who the server is acting for.

When to use a GitHub App

A GitHub App acts as the app itself.

  • Long-running org infrastructure, CI, bots
  • “The server should act, not on behalf of any specific user”
  • You want the smallest possible permissions
  • You want the audit log to show actions taken by the app

For “multiple researchers, multiple repos, multi-year operation,” this is essentially the only sensible choice.

OAuth App vs GitHub App, by flow

The two are easy to confuse. Here are the flows side by side.

OAuth App flow

[User]   → [App]:    "Use my GitHub permission to act on repo X"
[User]   → [GitHub]: clicks Allow on the consent page
[GitHub] → [App]:    issues a token bound to the user
[App]    → [GitHub API]: acts "as that user"

Operations run with user-level permission, with explicit user consent.

GitHub App flow

[Org admin] → [GitHub]: install the app on repos X and Y
[App]       → signs a JWT with its own private key
[App]       → [GitHub]: presents the JWT to obtain an installation token
[App]       → [GitHub API]: acts "as the app" against repos X and Y

Operations run with the app’s own permission, no user in the loop.

The whole difference boils down to “who does the app act as?” For our case—an admin console quietly calling workflow_dispatch—there’s no user to act on behalf of, so a GitHub App is the natural choice.

Creating the GitHub App, with minimum permissions

Create the App from the org’s settings (or your personal Developer settings). I configured it like this:

FieldValue
GitHub App name<your-org>-admin (must be globally unique)
Homepage URLhttps://admin.example.com
Callback URLempty (no user OAuth flow)
Webhook“Active” unchecked (we don’t consume events)
Repository permissionsActions: Read and write only
Organization permissionsAll “No access”
Installation targetOnly this account

Minimizing permissions is the point. With just Actions: Read and write, the app can fire workflow_dispatch and read run metadata. It cannot edit code, change secrets, or open issues. If the credentials ever leak, the most an attacker can do is run a workflow.

After creating the App, click Generate a private key to download the .pem. That’s the app’s identity.

Private Key vs Client Secret

The GitHub App page also shows a “Client secret” field, which is easy to confuse with the private key:

Private Key (.pem)Client Secret
TypeAsymmetric (public-key)Symmetric (shared secret)
PurposeSigning JWTs that authenticate the app itselfOAuth user-authentication code exchange
On the wireNever sent—you sign locally and GitHub verifies with the public keySent server-to-server (over HTTPS)
For our use caseRequiredNot used

If you don’t need OAuth user auth, you don’t need to generate a Client secret.

App ID vs Installation ID

Another easy confusion:

App IDInstallation ID
IdentifiesThe App itselfA specific installation of the App on an org/user
UniquenessOne per AppOne per installation
Used for“Which app” when issuing the JWT“Which installation” when exchanging JWT for installation token

Install the same App on two orgs and you get two Installation IDs against one App ID.

Getting an installation token

Each time the app calls the GitHub API, it signs a JWT with its private key and exchanges it for an installation token. @octokit/auth-app handles this for you.

import { createAppAuth } from '@octokit/auth-app'

const auth = createAppAuth({
  appId: process.env.GITHUB_APP_ID!,
  privateKey: process.env.GITHUB_APP_PRIVATE_KEY!,
  installationId: process.env.GITHUB_INSTALLATION_ID!,
})

const { token } = await auth({ type: 'installation' })
// Use token as `Authorization: Bearer ...` on Actions API calls

The token expires in 1 hour. If it leaks, the blast radius is one hour. On Cloudflare you store the private key as a Secret-type env var. PEM files have line breaks, which can be inconvenient, so base64-encoding is also fine.

Letting Cloudflare Access do the front-door auth

Authentication of the operator is not implemented in the app at all—Cloudflare Access takes care of it.

In the Zero Trust dashboard, register admin.example.com as a self-hosted application, choose One-time PIN (email OTP) as the identity provider, and list the allowed email addresses in the policy:

Application: admin.example.com
Identity Provider: One-time PIN
Policy:
  - Action: Allow
  - Selector: Emails
  - Value: <list of allowed email addresses>

Anything not on the allow-list is rejected at the Cloudflare edge before reaching the app. There’s no Basic-auth middleware in the Next.js code, and the standard Cloudflare audit log records who logged in and when.

The wins:

  • No auth code in the app to maintain
  • Unlike a shared password, removing a former colleague is one click in the policy
  • Per-person audit log lives at the edge for free
  • You can swap to an existing IdP (Google Workspace, Okta…) later without code changes

For Cloudflare Access to work, the domain must be proxied (orange cloud). DNS-only (gray cloud) requests bypass the edge entirely and the policy never fires.

The workflow on each target repo

Each target repo gets one .github/workflows/admin.yml that just calls existing scripts.

name: Admin - ES sync
on:
  workflow_dispatch:
    inputs:
      dry_run:
        description: 'Skip writes, just verify'
        type: boolean
        default: false

jobs:
  sync:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - env:
          ES_URL: ${{ secrets.ES_URL }}
          ES_USERNAME: ${{ secrets.ES_USERNAME }}
          ES_PASSWORD: ${{ secrets.ES_PASSWORD }}
          GOOGLE_CREDENTIALS_BASE64: ${{ secrets.GOOGLE_CREDENTIALS_BASE64 }}
        run: |
          if [ "${{ inputs.dry_run }}" = "true" ]; then
            npm run es:sync:dry
          else
            npm run es:sync
          fi

Elasticsearch and Google Spreadsheet credentials live as per-repo Secrets. The admin console only carries the GitHub App key. None of the per-site credentials are aggregated into the admin console—an important boundary.

The console front-end

The site config sits in one file. Adding a new site means one entry; the UI cards and API routes pick it up automatically.

// src/lib/sites.ts
export const SITES: SiteDef[] = [
  {
    id: 'u-renja',
    url: 'https://u-renja.example.com/',
    action: {
      type: 'github-workflow',
      repoEnvKey: 'U_RENJA_REPO',
      defaultRepo: 'your-org/u-renja',
      inputs: [{ name: 'dry_run', type: 'boolean', default: false }],
    },
  },
  // taishozo, kanseki, butten-shoshi ...
]

The API route resolves the site from siteId and dispatches the corresponding workflow.

// src/app/api/trigger/[siteId]/route.ts
export async function POST(req: NextRequest, ctx) {
  const { siteId } = await ctx.params
  const site = getSite(siteId)
  if (site?.action.type === 'github-workflow') {
    await dispatchWorkflow(repo, 'admin.yml', 'main', inputs)
    const { htmlUrl } = await fetchRunAfterDispatch(repo, 'admin.yml')
    return NextResponse.json({ ok: true, htmlUrl })
  }
}

fetchRunAfterDispatch queries the Actions API right after dispatch to get the URL of the just-started run. The UI uses that URL to take operators directly to the GitHub Actions log page.

What the design buys

  • Operator UX is dead simple: URL → email OTP → click a button. They never need to know GitHub or Vercel.
  • Survives churn: a GitHub App outlives its creator’s tenure; the App stays installed regardless of who comes and goes.
  • Least privilege: the console can only call the Actions API. It cannot mutate code, secrets, or issues.
  • Two-layer audit: “who signed in” (CF Access) and “what action ran” (GitHub Actions) are recorded independently.
  • Cheap to extend: a new site is one config-file entry plus one yaml in the target repo.

Picking the right auth, in one diagram

What kind of code are you writing?

├── A throwaway script on your own machine
│     → PAT is fine
├── A SaaS app where users connect their own GitHub
│     → OAuth App
└── A server that operates org repos long-term
      → GitHub App

PAT is the first thing that comes to mind when you think of yourself as the operator. The moment you cross the line into “infrastructure that the org runs,” you need a different tool. For our small-research-organization case, GitHub App + Cloudflare Access gave us the most-robust foundation with the least amount of code to maintain.

References