Self-hosting ID
Gateweave ID · v0.13.0 · source on GitHub
Gateweave ID runs on your own Cloudflare account as one Worker, with a D1 database for accounts, a Durable Object for rate limits and a Queue for logouts an app missed.
Use the Workers Paid plan. On the Free plan a request gets 10 ms of CPU, and checking a password takes about 100 ms. Email to any address needs the Paid plan too. ID has not been run on the Free plan.
There are two ways to deploy: the button, which also sets up deploys on every push, or the command line. Either way, claim your server as soon as it is up.
Deploy with the button
Section titled “Deploy with the button”The button copies this repository to your GitHub or GitLab account and connects the copy to Workers Builds, so every push to its main branch deploys. On the form:
- Git account is the first field, the account the copy goes to. If nothing is connected yet, connect GitHub or GitLab there.
- Project name names the Worker. It answers on
<project name>.<your subdomain>.workers.dev. - D1 database and Queue: choose Create new, unless you are moving a server you already run. Each then shows a name field that starts with this repository’s name. On a Cloudflare account that already runs ID, give both names of their own. There the form starts with the existing queue chosen, and two servers cannot share one.
- BETTER_AUTH_SECRET signs sessions. Use a long random string, for example from
openssl rand -base64 32, and keep it secret. Changing it later signs everyone out. - Leave the build and deploy commands as they are. The deploy command applies the database migrations before the new code goes live.
Escape closes the whole form rather than one list, and what you filled in goes with it.
Preview builds. The button turns them on: a push to any other branch builds a Preview with its own URL. A
Preview gets none of the Worker’s bindings or secrets, so its address only says that it is not set up, and it
cannot reach your data. To make Previews work, create another D1 database and apply the migrations to it, name
it, a queue and the Durable Object under previews in wrangler.jsonc as Cloudflare’s
guide shows, and give them a secret of their
own with pnpm wrangler preview base-config secret put BETTER_AUTH_SECRET. If you don’t need them, untick
Enable Preview builds on the form, or turn them off later under Settings › Build › Branch control.
Dependabot. The copy brings this repository’s .github/dependabot.yml, so Dependabot opens a pull request
on it within minutes, and weekly after that. You don’t need them: a release brings updates that were tested
together, and what you merge can conflict with the next update. Delete the file unless you want them, and
close what it opened.
Deploy from the command line
Section titled “Deploy from the command line”Requirements: Node.js 24 (22+ works) and pnpm 12.
git clone https://github.com/gateweavehq/id.gitcd idpnpm installpnpm wrangler loginpnpm run deploypnpm wrangler runs the Wrangler version ID pins, from the repository root. The Worker, its database and its
queue are named gateweave-id, gateweave-id and gateweave-id-logouts. To use other names, change name,
database_name and both queue lines at the top of wrangler.jsonc before the first deploy.
The first deploy creates the database and the queue, generates BETTER_AUTH_SECRET (it is never shown), then
applies the migrations. Every later pnpm run deploy builds, applies new migrations, and only then deploys.
Where the database lives
Section titled “Where the database lives”Every read and write of an account or a session goes to one place, the D1 database’s primary, and D1 puts it close to whoever asked for the database unless told otherwise. That may be far from your people and your apps. Measured from a Worker with the primary in Eastern Europe, one read takes about 20 ms from Frankfurt, 110 ms from Virginia, 240 ms from Tokyo and 290 ms from Sydney (how).
To choose, make the database yourself before the first deploy, with a location hint (wnam, enam, weur, eeur,
apac or oc):
pnpm wrangler d1 create gateweave-id --location apacThen pick it instead of Create new on the button’s form, or put its id in wrangler.jsonc as database_id.
Claim your server
Section titled “Claim your server”Until a server has an account, whoever reaches it first owns it. So open it as soon as it is up, and go to
/signup.
With neither email nor GitHub set up, which is how a new deployment starts, the page offers to claim the server: a name, an address and a password. Nothing checks the address, since the server cannot write to it, so use your real one. With GitHub set up and no email, the page offers only GitHub, and your first sign-in with it claims the server. With email on, you sign up with a code like anyone else, and the first to do so is the owner.
Who can get in
Section titled “Who can get in”Sign-up closes behind the first account. Your account page then has a This server section, which opens
/server, a page only the owner can find:
- Who is on the list. Addresses, or domains written with an @ such as
@example.com, that can make an account while sign-up is closed, with no invitation. A domain covers addresses at exactly that domain, not its subdomains. Each entry can have a note, and the page shows when it was added and whether an account with that address, or at that domain, exists yet. The list holds at most 5,000 entries. It names people who may have no account here, so it is personal data, and it stays until you remove an entry. The page asks for a recent sign-in before it changes anything. - Invite someone to this server. With email on, they get an email. Without it, you get a link to pass on yourself. It works once, for 7 days.
- Open sign-up, if the server is meant for anyone. Anyone who finds it can then make an account and a workspace. Closing it again stops new accounts, and leaves the ones already made.
- Help someone back in, on a server without email, where nobody can reset a password for themselves.
While sign-up is closed, only the owner makes workspaces, because a workspace’s invitation is a way in. Those the owner makes admins of a workspace can invite to it, and so let people in. A workspace you are not an admin of, made while sign-up was open, cannot invite anyone until it opens again. Its admins cannot make you an admin of it meanwhile, since that would let it invite. To become one, open sign-up first.
To hand the server to someone else, who needs an account already, run this from the repository root:
pnpm wrangler d1 execute DB --remote --command \ "UPDATE deployment SET owner_id = (SELECT id FROM user WHERE email = 'someone@example.com') WHERE id = 1"The owner’s account cannot be deleted until someone else runs the server. The same statement gives a server an
owner if it has none. Until then nobody can reach /server, and sign-up stays as it was.
Without email
Section titled “Without email”ID works without email, with less. Compared with a server that sends it:
-
Nothing checks an address. The owner’s word stands in for it: whoever opens an invitation link gets an account with that address, so send links only to the person they are for.
-
Nobody can reset a forgotten password for themselves. You can help: Help someone back in on
/servermakes a link for choosing a new password for their account. It works once, for 24 hours, and making another replaces it. Whoever opens it can choose the password, so send it only to them. Using it signs them out everywhere and leaves two-factor sign-in on, and their activity shows that you made it. The page asks for your own password, and a sign-in from the last day. Nobody can make one for you, so if you are the one locked out, write the same thing yourself, from the repository root:Terminal window TOKEN=$(openssl rand -hex 32)pnpm wrangler d1 execute DB --remote --command \"INSERT INTO verification (id, identifier, value, expires_at) SELECT '$TOKEN', 'reset-password:$TOKEN', owner_id, (unixepoch() + 3600) * 1000 FROM deployment"echo "https://your-server/reset-password#$TOKEN" -
A workspace’s invitations are not sent. They wait for the person under Workspaces, once they have an account here, which only the owner can give them. Tell them yourself.
-
GitHub does not reach an account that already has its address. Nothing proved that address, so whoever opened a stray link could otherwise share the account of the person who later signs in with GitHub. The person signs in with the account’s password or a passkey instead, and the sign-in page says so.
Settings
Section titled “Settings”Everything except BETTER_AUTH_SECRET is optional. Set them in the Cloudflare dashboard (Workers & Pages › your
Worker › Settings › Variables and Secrets) or from the repository root:
pnpm wrangler secret put BETTER_AUTH_URLText variables and secrets both stay through a deploy, because wrangler.jsonc sets keep_vars. A copy older than
v0.10.1 lost its text variables on every deploy, so look through them after updating. A site key missing there means
Turnstile was off.
| Name | What it does |
|---|---|
BETTER_AUTH_SECRET |
Signs sessions. Required. |
BETTER_AUTH_URL |
The address people use, such as https://id.example.com. See below. |
OIDC_PAIRWISE_SECRET |
With BETTER_AUTH_URL, turns on the OpenID Connect provider. See below. |
EMAIL_FROM |
Turns on email, such as Gateweave ID <id@example.com>. |
GITHUB_CLIENT_ID |
Turns on “Continue with GitHub”, with GITHUB_CLIENT_SECRET. |
GITHUB_CLIENT_SECRET |
The client secret of the same GitHub OAuth App. |
TURNSTILE_SITE_KEY |
Turns on Turnstile, with TURNSTILE_SECRET_KEY. |
TURNSTILE_SECRET_KEY |
The secret key of the same widget. |
CHECK_BREACHED_PASSWORDS |
1 refuses passwords known from breaches. |
LEGAL_NOTICE_URL |
The address of your legal notice. See below. |
PRIVACY_POLICY_URL |
The address of your privacy policy. |
TERMS_URL |
The address of your terms. |
SIGN_UP_REQUEST_URL |
An https address where people can ask for an account. See below. |
Your own domain
Section titled “Your own domain”Do this first. In the dashboard, open the Worker and add a custom domain under Settings › Domains & Routes. Or
add "routes": [{ "pattern": "id.example.com", "custom_domain": true }] to wrangler.jsonc. Sign-in needs
HTTPS: session cookies are marked Secure, so a browser won’t keep them over plain HTTP. Turn on
Always Use HTTPS
for the domain so that nobody lands on the plain address.
Turning on the provider
Section titled “Turning on the provider”Accounts and workspaces work without this. Apps cannot sign anyone in until it is on, and passkeys are off too, because each passkey is bound to the address for good.
-
Set
BETTER_AUTH_URLto the address. It becomes the issuer that apps compare, so it cannot change once any app has signed anyone in, and neither can the passkeys made on it. -
Generate
OIDC_PAIRWISE_SECRET, keep a copy in your password manager, then set it. It gives each app its own id for each person. Changing or losing it gives everybody a new id in every app, and secrets cannot be read back:Terminal window openssl rand -base64 48pnpm wrangler secret put OIDC_PAIRWISE_SECRET
https://id.example.com/.well-known/openid-configuration then answers, with your address as the issuer.
Connecting apps
Section titled “Connecting apps”An owner or admin of a workspace can add an app from its page, and only that workspace’s members can use it. connect-an-app.md has the steps.
An app for everyone on the server is registered from the repository root instead, and nobody is asked about it before they sign in. Its secret is printed once:
pnpm --filter @gateweave/id-web apps:add --app wiki --name "Team wiki" \ --redirect-uri https://wiki.example.com/oidc/callbackAdd --client-auth client_secret_post if the app’s library sends its secret in the body of the request, and
--no-pkce if it cannot do PKCE, in which case the app has to send a nonce and check it.
Add --backchannel-logout-uri if the app has one, and signing out of ID then ends the session there. An app that
is down when that happens gets it again for about 20 hours.
If the app is a Worker too, give each a
Custom Domain.
A Worker cannot reach another on its own zone through a route, and two on workers.dev cannot reach each other at
all. Gateweave Track’s self-hosting guide
has what to do on its side.
- Onboard a domain to Cloudflare Email Sending:
pnpm wrangler email sending enable example.com. - Set
EMAIL_FROMto a sender on that domain, such asGateweave ID <id@example.com>.
ID then sends sign-up codes, password resets and invitations. The form that claims a server without a code goes away.
Nothing proved the addresses of accounts made before this, and GitHub links to an account by its address once email is on. If a link you sent may have reached the wrong person, delete that account before you turn email on.
Sign in with GitHub
Section titled “Sign in with GitHub”Create a GitHub OAuth App whose redirect URI, which GitHub has also
called the callback URL, is https://id.example.com/api/auth/callback/github (your address), then set
GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET. The form that claims a server goes away here too, since GitHub can prove an address and
the form cannot.
Turnstile
Section titled “Turnstile”Set this on any server open to the internet. It stands in front of password sign-in, of sending email and of the form that claims a server. GitHub does not pass through it, so on a server with GitHub and no account yet, the first person to sign in with a verified address there owns it.
- In the Cloudflare dashboard, add a Turnstile widget for your hostname (Managed mode).
- Set
TURNSTILE_SITE_KEYto its site key andTURNSTILE_SECRET_KEYto its secret key, the second as a secret.
Breached passwords
Section titled “Breached passwords”With CHECK_BREACHED_PASSWORDS set to 1, ID refuses passwords known from breaches. It sends the first five
characters of a password’s SHA-1 to Have I Been Pwned, so it is yours to choose.
Where to ask for an account
Section titled “Where to ask for an account”While sign-up is closed, the sign-up and sign-in pages can point people at a way to ask for one. Set
SIGN_UP_REQUEST_URL to an https address, such as the page of your waiting list, and they show “Ask for
access” linking to it. It is left out while sign-up is open, and when the value is not an https address.
Your legal pages
Section titled “Your legal pages”ID comes with no legal notice, privacy policy or terms, and never links to Gateweave’s: whoever runs a copy decides
what applies to it. Set any of the three addresses, and it is linked from the sign-in pages, the page that asks to
allow an app, and the account and workspace pages. With TERMS_URL set, the sign-in pages also say that continuing
means agreeing to them.
Someone who is locked out
Section titled “Someone who is locked out”Someone who has lost their authenticator and their recovery codes, and has no passkey or GitHub to get in with, can have two-factor sign-in turned off. Be sure who is asking first, since this is what stands between a stolen password and their account:
pnpm --filter @gateweave/id-web two-factor:remove --email someone@example.comBackups
Section titled “Backups”D1 keeps Time Travel for you, always on, for 30 days on the Paid plan. To go back, which overwrites the database in place:
pnpm wrangler d1 time-travel restore DB --timestamp=<unix time>To keep a copy elsewhere, pnpm wrangler d1 export DB --remote --output=id.sql. It holds password hashes and everyone’s
addresses, so keep it as safe as the database.
Updating
Section titled “Updating”Command line: git pull, pnpm install, pnpm run deploy.
Button: your copy does not follow this repository on its own, and shares no history with it, so git pull
refuses. This takes the latest release into your copy instead, as one commit on top of it. A copy can skip releases,
however old it is:
git remote add upstream https://github.com/gateweavehq/id.git # oncegit fetch upstreamgit show upstream/main:scripts/update-copy.mjs | node --input-type=modulegit pushThe script pushes nothing, and git reset --hard HEAD~1 undoes it until you do. The push is what deploys: Workers
Builds builds it and applies the D1 migrations.
If GitHub refuses the push with push declined due to email privacy restrictions, the commit carries the address
your GitHub settings keep private. Use the noreply address those settings show you, and make the commit again:
git config user.email "<your noreply address>"git commit --amend --reset-author --no-editgit pushWhat your copy changed stays: the Worker’s, database’s and queue’s names in wrangler.jsonc, the name in
package.json, and having no workflows. If something else you changed conflicts with the release, the script names
the files for you to fix before committing. To take a release other than the latest, end the third line with
node --input-type=module - --to v1.2.3.
ID is in early development. Read CHANGELOG.md before updating, as some releases change how things work.