Emet needs a GitHub token to read and write your repository. The quick way to get one is a personal access token, and Emet still accepts those, but asking every editor to generate a PAT with the right scopes is a faff and they expire at awkward moments. A GitHub App gives you a Sign in with GitHub button instead, with short-lived tokens that Emet refreshes itself.
Under the hood this is the OAuth authorization code flow with PKCE. The browser sends people to GitHub, GitHub sends them back with a code, and a Pages Function at /api/github/token swaps the code for a token, adding the app’s client secret on the way. The secret never reaches the browser.
What you’ll need#
- Admin rights on the GitHub account or organisation that owns the site repositories.
- An Emet deployment on Cloudflare Pages, which is where the token exchange function runs. If you host elsewhere, you’ll need to provide an equivalent endpoint and point
VITE_API_BASE_URLat it; see Self-hosting. - Node 22.12 or later if you’re building locally.
Creating the app#
- Go to GitHub Settings, then Developer settings, then GitHub Apps, and click New GitHub App. For an organisation-owned app, start from the organisation’s settings instead.
- Fill in the basics. GitHub App name is what people see on the consent screen, so “Example Site CMS” is better than “emet-prod-2”. Homepage URL is your CMS URL.
- Set Callback URL to the exact URL the CMS is served from, including the trailing slash, e.g.
https://cms.example.com/. Emet uses the page’s origin and path as the redirect URI, so a mismatch here is the commonest reason sign-in fails. Click Add Callback URL and addhttp://localhost:5173/as well if you’ll be developing locally. - Tick Request user authorization (OAuth) during installation. This lets a new person install the app and sign in in one go.
- Leave Expire user authorization tokens ticked. Emet handles the refresh.
- Under Webhook, untick Active. Emet polls; it doesn’t need webhooks.
- Under Permissions, expand Repository permissions and set the ones below. Leave everything else at No access.
- Under Where can this GitHub App be installed?, choose Only on this account for a personal site or Any account if the repositories belong to an organisation or you want other people to install it on theirs.
- Click Create GitHub App. You land on the app’s settings page.
Permissions#
| Permission | Access | Needed for |
|---|---|---|
| Contents | Read and write | Everything. Reading and committing content, config and media. |
| Metadata | Read-only | Set automatically; you can’t turn it off. |
| Pull requests | Read and write | The publication workflow: opening and merging PRs, reviews, review threads and PR comments. Skip it if you’ll never set production_branch. |
| Actions | Read-only | Showing workflow runs for the PR in the publish modal. |
| Checks | Read-only | Showing check runs and merge readiness. |
| Deployments | Read-only | The View Staging Site link, when your host reports deployments to GitHub. |
| Administration | Read-only | Reading branch protection rules so the modal can explain why a PR isn’t mergeable. Optional: without it Emet just shows less. |
The last four only matter for the publication workflow. Contents alone is enough to edit a site.
Collecting the credentials#
- On the app’s settings page, copy the Client ID. It looks like
Iv23.abc123def456. - Note the slug from the page URL:
github.com/settings/apps/<slug>. It’s the name in lowercase with hyphens. - Click Generate a new client secret and copy the secret. GitHub shows it exactly once; if you lose it, generate another.
Installing the app#
- In the app’s settings sidebar, click Install App.
- Choose the account or organisation.
- Choose All repositories or Only select repositories and pick the site repositories, then click Install.
You should land on the installation’s settings page. Repositories you didn’t install the app on won’t appear in Emet’s repository picker, and the token can’t read them even if your user can, so come back here when you add a site.
Configuring Emet#
Two of the values are baked into the JavaScript at build time and one is read by the Pages Function at request time:
| Variable | Where | Value |
|---|---|---|
VITE_GITHUB_CLIENT_ID |
Build | The Client ID. Without it, the login screen shows only the PAT form. |
VITE_GITHUB_APP_SLUG |
Build, optional | The app slug. With it, the repository picker lists only repositories the app is installed on and offers an Install GitHub App link when there are none. Without it, the picker lists every repository your user can see. |
GITHUB_CLIENT_SECRET |
Runtime | The client secret, for the token exchange. |
On Cloudflare Pages, add all three under the project’s Settings, Variables and Secrets, marking the secret as a secret, and trigger a new build so the VITE_ ones get picked up. For a local build:
VITE_GITHUB_CLIENT_ID=Iv23.abc123def456 VITE_GITHUB_APP_SLUG=example-site-cms npm run buildCloudflare Pages has the rest of the deployment setup.
Testing it#
- Open the CMS. You should see Sign in with GitHub.
- Click it. GitHub asks you to authorise the app (and, the first time, where to install it).
- Approve. You’re sent back to the CMS, which exchanges the code and shows the repository picker with your recent and installed repositories.
- Pick one. If it has no
admin/site.yamlyet, the setup wizard runs.
That’s it! Tokens live in the browser’s local storage. Emet refreshes the access token five minutes before it expires, retrying up to three times if GitHub is having a moment; if the refresh token itself is rejected, you’re asked to sign in again and your unsaved drafts are kept.
InfoShared computers. Because tokens are in local storage, use Sign Out in the user menu when you’re done on a machine that isn’t yours. Signing out clears them.
Local development#
npm run dev normally uses the local filesystem backend and never talks to GitHub. To exercise the real sign-in flow locally:
VITE_FORCE_AUTH=1 VITE_GITHUB_CLIENT_ID=Iv23.abc123def456 npm run devVITE_FORCE_AUTH switches the dev server to the GitHub backend and proxies /api to localhost:8788, which is where wrangler pages dev serves the Pages Functions. Run that in a second terminal:
npm run build
npx wrangler pages dev dist --port 8788Wrangler reads GITHUB_CLIENT_SECRET from a .dev.vars file in the project root (it’s gitignored). You’ll also need http://localhost:5173/ in the app’s callback URLs.
When it goes wrong#
- “Invalid OAuth state” or “Missing PKCE verifier”: the state and verifier are kept in session storage for the duration of the round trip, so finishing the flow in a different tab or browser, or with storage blocked, loses them. Start again from the sign-in button in one tab.
- The redirect never comes back, or GitHub shows a redirect_uri error: the callback URL on the app doesn’t match the CMS URL exactly. Check scheme, host, path and trailing slash.
- Token exchange fails right after approving:
GITHUB_CLIENT_SECRETisn’t set on the Pages project, or the Pages Functions didn’t deploy.curl -X POST -d '{}' https://cms.example.com/api/github/tokenshould come back with a JSON error about a missingclient_id, not a 404. - “No repositories authorized” or a repository is missing from the list: the app isn’t installed on it. Go to the app’s Install App page and add it.
- Changes aren’t saving: the app has Contents read-only, or you’re on a branch protected against direct pushes.
Using a personal access token instead#
The login screen has Use Personal Access Token instead. A classic token with the repo scope (or public_repo for public repositories) works, and if VITE_GITHUB_CLIENT_ID isn’t set this is the only option. PATs don’t refresh, so when one expires you’ll be signed out, and the repository picker shows everything your user can access since there’s no installation to filter by. It’s fine for a single-person site or for debugging; for a team, set up the app.
Where next?#
- Environment variables for the full list, including
VITE_API_BASE_URL. - Publication if you want the review workflow the extra permissions enable.
- Google Search Console for the other OAuth integration.