Everyone who signs in to the site has an account: customers who register, and the editors and admins who run the site. This chapter shows how people sign up and sign in, how to add a “Continue with Google” or “Continue with Pocket ID” button, and how to decide who gets into the admin. By the end you will have people signing in with a password and, if you want it, with Google or Pocket ID, and you will know who can reach the admin.
Accounts need nothing to be installed. A site always has them.
Every account has an email address, a name and one of three roles:
/admin.A customer who signs in at /admin is told the admin is not for them and sent back to the site. Registering never grants a staff role; an admin does that.
You can also make an account from the command line, in your site's folder. This sets the password straight away, and it is the way in when you have no admin yet. Use --role editor or --role customer for the other roles:
pnpm cms user:create-admin --email you@example.com --password 'choose-something-long'On a server that runs Docker, put docker compose run --rm app in front of the command, as in Run it on a server.
Open a person under Users to edit their name or email, change their Access (the role), send a password reset or a confirmation email, or choose “Mark as confirmed” for an address you know is theirs.
Public sign-up is off by default, so the register form and the link to it show nothing until you turn it on. Sign-in itself works either way, and so does every account an admin makes.
A visitor who registers is sent a confirmation link, and the account signs in once they click it. “Forgotten your password?” on the sign-in form sends a reset link. Customers change their name, email and password on their account page, and a changed email is confirmed at the new address before the account moves.
Sign-in, registration and the reset request each run a small proof-of-work check in the visitor's browser, plus a CSRF token, so bots have little to work with. Nothing is shown to the visitor. Where the check cannot run, such as a plain-http address on your local network, add security: { captcha: false } to site.config.ts.
With the shop, a guest checkout creates an account for the email address used. It has no password and the address is unconfirmed, so the order has an owner to come back to. The page after payment offers to set a password straight away, and a reset link does the same later. Until a password is set, the order opens from the link in its emails.
A sign-in provider lets people use an account they already have instead of a new password. Each one adds a button such as “Continue with Google”. The button appears on both sign-in pages: the site's sign-in form, and the admin's sign-in page, which is also the page Claude or ChatGPT sends you to when you connect it.
Each provider is its own package, and you install only the ones you use. They are listed under auth.providers in site.config.ts. With none listed, people sign in with an email address and a password. The setup for Google comes first, then Pocket ID, and “What happens when someone uses a button” explains what a visitor gets after pressing one.
pnpm add @jamiemcconnell/cms-auth-googlesite.config.ts:import { google } from '@jamiemcconnell/cms-auth-google';
export default defineConfig({
// …
auth: { providers: [google()] },
});CMS_SITE_URL followed by /api/auth/callback/google, such as https://example.com/api/auth/callback/google. Add one for each address you sign in from, such as http://127.0.0.1:4321/api/auth/callback/google for development. Copy each exactly: the scheme, the host, the port, and no trailing slash..env:GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret/admin. “Continue with Google” is under the password form. A site that lists google() without both variables does not start; the error names the missing ones, because a button that cannot work is never shown.If your secrets live under other names, say so in the config: google({ clientIdEnv: 'MY_GOOGLE_ID', clientSecretEnv: 'MY_GOOGLE_SECRET' }).
hostedDomain restricts sign-in to one Google Workspace domain, as in google({ hostedDomain: 'example.com' }). Use '*' for any Workspace domain. Personal Google accounts are refused either way.prompt sets what Google asks the person. select_account, the account chooser, is the default; consent, login and none are the others.A self-hosted identity provider such as Pocket ID, which signs people in with passkeys, works like Google. So do Keycloak, Authentik, Authelia and Zitadel, which all speak OpenID Connect. There are two differences. You tell the site where your provider lives, because it has no fixed address. And the provider can optionally decide who is an editor or an admin.
pnpm add @jamiemcconnell/cms-auth-oidcsite.config.ts. Pocket ID has a preset:import { pocketId } from '@jamiemcconnell/cms-auth-oidc';
export default defineConfig({
// …
auth: { providers: [pocketId()] },
});Any other provider is named by you, as in oidc({ id: 'keycloak', label: 'Keycloak' }). The id is lowercase letters, digits and dashes. It becomes part of the callback address and of the environment variable names below. The label is what the button says: “Continue with Keycloak”. You can list Google and several OpenID providers together; their buttons follow the order of the list.
CMS_SITE_URL followed by /api/auth/callback/pocket-id, such as https://example.com/api/auth/callback/pocket-id. In the admin, Settings → Users → Sign-in providers shows the exact address for every listed provider, with a Copy button. For another provider the last part is its id. Add one callback for each address you sign in from, copied exactly, with no trailing slash..env. The issuer is the address you browse to Pocket ID at:OIDC_POCKET_ID_ISSUER=https://id.example.com
OIDC_POCKET_ID_CLIENT_ID=your-client-id
OIDC_POCKET_ID_CLIENT_SECRET=your-client-secretThe names follow the provider's id in capitals, with dashes turned into underscores. For keycloak they are OIDC_KEYCLOAK_ISSUER, OIDC_KEYCLOAK_CLIENT_ID and OIDC_KEYCLOAK_CLIENT_SECRET. Three details:
https address with no path after the host, unless the provider uses one (Keycloak does: https://keycloak.example.com/realms/main). Plain http is accepted for localhost and 127.0.0.1 only, for development.ADAPTER_ENV_MISSING or CONFIG_INVALID, naming it. Those are mistakes in the setup, so the site refuses to run without the sign-in you asked for./admin. “Continue with Pocket ID” is under the password form. The person needs a Pocket ID account, and the rules in “What happens when someone uses a button” decide what happens next.Whichever provider you use, the same rules apply when someone presses its button:
The settings below are for Pocket ID and the other OpenID providers. Google has no equivalent.
A provider says whether it has checked an email address with the email_verified claim in the token it returns. The site treats an address as confirmed only when the provider says exactly true. That matters for linking onto an existing account, for being allowed in as staff, and for a new account starting out confirmed.
Pocket ID marks the addresses of users an administrator creates by hand as not verified, including your own. Until that changes you would sign in as a customer. There are two ways round it:
trustEmail: true to the provider, as in pocketId({ trustEmail: true }). Every address from this provider then counts as confirmed. Use it only when you are the only person who can create accounts there. Never use it with a provider that lets anyone register with any address.trustEmail off. Look for an email-verified option on the user or in its settings, which varies between versions.Without roles, everyone who signs in through the provider is an ordinary account, and you set your editors and admins under Users. With roles, the provider decides:
pocketId({
trustEmail: true,
roles: { admin: ['cms-admins'], editor: ['cms-editors'] },
});In Pocket ID, open Administration → User Groups, create groups with those names, and add people to them. Use each group's name, which is what appears in the token, not its friendly name. The site asks for the groups scope by itself once roles is set. To read the groups from a differently named claim, write roles: { claim: 'your-claim', … }; a dotted name such as realm_access.roles (Keycloak) reaches into the object. At least one of admin and editor is needed.
Only one provider in the list may set roles, staffOnly or autoRedirect. The site refuses to start with two.
Add staffOnly: true and editors and admins must use the provider. A password sign-in by one of them is refused with AUTH_PROVIDER_REQUIRED, and the form tells them to use the button. A wrong password still gets the usual “wrong password” message, so the form never reveals which addresses belong to staff. Customers are unaffected.
The provider is now critical to getting in, so keep a way back:
CMS_AUTH_LOCAL_STAFF=1 in .env and restart. Staff can use their passwords again while it is set, and every such sign-in is written to the audit log. Remove it when the provider is back.docker compose run --rm app pnpm cms user:create-admin still works on a server, because it writes to the database directly.Add autoRedirect: { admin: true, oauth: true } and a signed-out visitor to /admin, or to the page where Claude or ChatGPT connects, goes straight to the provider. Add ?local=1 to the address, as in /admin?local=1, to see the password form anyway. Signing out of the admin lands there too, so you are not carried straight back in. The site's own /login page always shows its form with the button.
A provider that cannot be reached does not stop the site. The site reads the provider's discovery document when it starts. If there is no answer, it logs oidc provider unreachable with the reason, hides that provider's button, and tries again about once a minute. Settings → Users → Sign-in providers shows the provider as Unreachable. A sign-in that names it meanwhile is refused with AUTH_PROVIDER_UNREACHABLE. When the provider answers again the button comes back without a restart.
With staffOnly on, staff passwords stay refused during an outage, so an outage cannot be used to get round the provider. Use CMS_AUTH_LOCAL_STAFF=1 as above.
Buttons are added for you. Every listed provider gets one on each sign-in screen, in list order, under the password form. There is nothing to switch on in the editor, and pages built before you added a provider show its button without being edited. The Sign-in form block on the site's sign-in page holds the words, so you can change them in your own voice:
{provider} becomes the provider's name. The admin's sign-in page, which is also the one Claude and ChatGPT use, has fixed English.
CMS_SITE_URL must be the address you really browse to. Changes at Google can take a few minutes to apply..env and restart.email_verified as true or that trustEmail is on, that the group's name matches the one in roles exactly, and that the groups claim is in the token. Roles are only updated at sign-in, so sign in again after fixing it.CMS_AUTH_LOCAL_STAFF=1, restart, and sign in with a password; pnpm cms user:create-admin makes an admin if you have none. If autoRedirect keeps sending you away, use /admin?local=1.auth.providers, the site was not restarted, the provider is unreachable, or the page is cached.Run it on a server lists the environment variables, and Troubleshooting covers problems that are not about sign-in.