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 App | GitHub App | |
|---|---|---|---|
| Owned by | Individual user | App (personal or org) | App (org-owned) |
| Acts as | The token holder (a user) | What the end-user authorized | The repos the app is installed on |
| Token lifetime | Up to 1 year, manual rotation / or no expiry | No expiry by default (8 hours + refresh is opt-in) | 1 hour, auto-refreshed |
| When someone leaves | Their tokens die with them | Revoke per-user | No effect |
| Permission granularity | Function × repo | OAuth scope | Function × repo |
| Audit logs | Limited | Yes | Yes (action attributable to the app) |
| Typical use | Personal scripts, CI secrets | “Connect your GitHub” SaaS | A 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:
| Field | Value |
|---|---|
| GitHub App name | <your-org>-admin (must be globally unique) |
| Homepage URL | https://admin.example.com |
| Callback URL | empty (no user OAuth flow) |
| Webhook | “Active” unchecked (we don’t consume events) |
| Repository permissions | Actions: Read and write only |
| Organization permissions | All “No access” |
| Installation target | Only 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 | |
|---|---|---|
| Type | Asymmetric (public-key) | Symmetric (shared secret) |
| Purpose | Signing JWTs that authenticate the app itself | OAuth user-authentication code exchange |
| On the wire | Never sent—you sign locally and GitHub verifies with the public key | Sent server-to-server (over HTTPS) |
| For our use case | Required | Not 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 ID | Installation ID | |
|---|---|---|
| Identifies | The App itself | A specific installation of the App on an org/user |
| Uniqueness | One per App | One 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.