Your first account
One CLI command creates the first admin. What the store is, why sign-up cannot make itself an admin, and how the throttles behave when you get a password wrong.
Everything under /admin/ is behind a server-side guard, so the first thing a fresh install needs is an account. There is no environment variable to set and no database to provision — the store is a gitignored .accounts/ directory at the repository root, and one command writes to it.
Create it
node --experimental-strip-types src/admin/js/accounts.cli.ts [email protected] "Your Name"
It asks for the password on stdin — never as an argument, which would land in your shell history — applies the same rules the sign-up form does, and hashes with Argon2id at OWASP’s parameters (m=19456 t=2 p=1, about 383 ms on real hardware). It prints where the record went.
The third argument is the role, and it defaults to admin:
node --experimental-strip-types src/admin/js/accounts.cli.ts \
[email protected] "Someone Else" editor
Run it again for an address that already exists and it replaces that account’s password, name and role — which is also how you reset a password you have lost.
This command must run under plain
node, which resolves no tsconfig paths. That is a real constraint the codebase enforces with a test that actually runs the CLI as a subprocess: v1.0.0 shipped one aliased import inside it and every fresh install was locked out of its own CMS. If you ever modify anything reachable from this file, keep it alias-free.
Then sign in
Start the dev server, open /admin/, and sign in with that address. The password floor is 12 characters — above NIST’s eight, and matched to the strength meter, which tops out at the same number.
The three roles
| Role | Can |
|---|---|
admin |
Publish and approve changes · manage the team and invites · change settings and field overrides · connect and disconnect the project · everything a client editor can do |
editor |
Write and edit entries · upload media and write alt text · submit changes for review · preview the site. Cannot publish, approve or manage the team |
none |
Nothing. Sign-in refuses at the door |
none is a real, selectable state rather than the absence of one — taking access away is a role change, not a deletion, so the person’s account and history stay intact.
A role change lands on the demoted person’s next request, not at their next sign-in. The guard re-reads the account from the store on every guarded request rather than trusting the session payload, which costs about 0.45 ms and means one refusal covers no session, deleted account, demoted to none, and a stale session all at once.
Why sign-up cannot let itself in
/admin/signup/ is public, and anyone who reaches it can create an account. That account gets role: "none", which sign-in refuses until an admin grants access.
This is the whole design. A self-serve sign-up that granted itself access would hand a fresh deployment to whoever loaded the page first. It is also why the CLI exists: on a fresh install, sign-up is not a route to an admin account, and there is no other way in.
Sign-up also needs mail configured. Without RESEND_API_KEY and MAIL_FROM, the screen answers “we can’t create accounts right now, please try again later” — which is permanent rather than transient, because confirming a mailed link is how the address is proved. Use the CLI for the first account either way; it is the documented path. See Environment variables.
Inviting everybody else
Once you are in as an admin, /admin/team/ is the normal route: an invite mints a token, stores it server-side, mails the link, and hands it back to the modal so you can copy it. The invited person sets their own password on arrival and lands with the role you chose. Full detail in Team, roles and invites.
Both token routes spend the token on POST, never on the GET that loads the page — a mail scanner following the link used to burn the invite before its recipient saw it.
The throttles
Three separate scopes, and they behave differently on purpose.
Sign-in locks out after 10 attempts in 15 minutes, for 4 minutes. A miss pays for a password hash too, so an unknown address and a wrong password cost the same amount of time and neither can be used to enumerate the other.
Sign-up is a separate scope from sign-in. If it were not, anyone could lock an address out of its own account through the sign-up form.
Sign-in also sheds a burst. Past four requests in flight, the endpoint answers 429 with a real Retry-After and the screen draws its existing countdown. The hash is synchronous and single-threaded, so this is about the check-then-act window rather than about CPU: a simultaneous burst would otherwise all pass the lockout check before any of them recorded a failure.
Where the store lives
.accounts/, gitignored. Deliberately not under .astro/ or node_modules/.astro/, because losing it deletes the only account with access to your CMS — and rm -rf node_modules is a thing people do on a Tuesday.
It is relative to the working directory, not to your project
This is the single most likely thing to bite you, and it is invisible when it does.
The path is the literal string .accounts, resolved by the storage driver against process.cwd(). It lands at your repository root only because that is where you normally start the server. Start it from anywhere else — or run the accounts CLI from anywhere else — and the CMS reads and writes .accounts/ in that directory instead.
Nothing warns you. An empty store looks exactly like a fresh install, which means the sign-in you expected to work instead re-opens first-admin sign-up to whoever loads the page.
So run both from the project root:
cd ~/projects/my-site
node --experimental-strip-types src/admin/js/accounts.cli.ts [email protected] "Your Name"
pnpm dev
The CLI prints where the record went. If that path is not the .accounts/ beside your package.json, your cwd is wrong — that line is the check, and it is worth reading rather than skipping.
The same applies to the deployed standalone server, which is why the container starts inside its checkout. It also disappears entirely the moment the store is a database or a KV binding, because neither has a path to resolve.
It is an unstorage namespace, so the same drivers Astro uses for sessions — Cloudflare KV, Vercel KV, Netlify Blobs, Redis, S3 — drop in with one line in src/admin/js/accounts.ts. For the destination most people asking that question actually mean, a real database, there is no line to change at all: see Bring a database.
Every store key is a SHA-256 hex digest of the value, which keeps email addresses off disk and sidesteps driver character-set limits.
Troubleshooting
ERR_MODULE_NOT_FOUND running the CLI. You are on v1.0.0. That release shipped an aliased import in the one file that must not have one; 1.0.1 is the fix and the reason to take it.
Sign-in says the account has no access. The role is none. Either you signed up through the form (which is what it does) or an admin demoted you. An admin can promote from /admin/team/.
Sign-up says accounts cannot be created right now. Mail is not configured. That message is permanent, not transient.
You are locked out with no admin. Run the CLI again for your own address with no third argument; it replaces the role with admin along with the password.
Sign-in does not recognise an account you definitely created, or the CMS offers first-admin sign-up again. Your process’s working directory is not the project root, so .accounts/ resolved somewhere else and the store it found is empty. See It is relative to the working directory above. Check for a stray .accounts/ in whatever directory you started from.
The store vanished after a redeploy. .accounts/ lives inside the checkout. If your container’s volume does not hold the checkout, or you did not attach one, every redeploy is a fresh install. Deployment covers the volume, and Bring a database covers making the machine disposable instead.