Platform
Hosting & deploysPush to a branch, get a live linkDatabaseReal Postgres, one per appSign-inEmail and Google, users includedFile storageUploads, photos and documentsAI gatewayEvery model, with a hard spend cap
Resources
DocsGuides written for people who are newFor AI agentsClaude Code, Cursor, MCP, JSON CLICompareCamplax beside the stack it replacesPricingFrom $25/month plus prepaid creditsSupportA person answers, not a queue botPoliciesTerms, privacy and how we handle data Get started
Open in ChatGPT Open in Claude Last updated 2026-11-07

Authentication

Better Auth on your app's own Postgres — email and password, passkeys, OAuth, two-factor and real sessions, with users in a table you can query.

Accounts are what turn a demo into an app, and the thing most first projects get wrong. On Camplax, sign-in arrives connected to the database: users land in a real table, sessions are handled properly, and the rules that keep people to their own rows are on from the start.

This page describes the Plax starter and the AI chatbot starter, which share the same sign-in code. An app you bring yourself gets the same settings in its environment, but uses them only if its own code reads them.

What your app gets

  • Email and password sign-in is on by default.
  • Refuse passwords from known breaches is on by default. A new password is checked against Have I Been Pwned's list without the password leaving your app — only the first five characters of its SHA-1 hash are sent. If that service is down or slow (2.5 seconds), the password is allowed and the miss is logged, so an outage elsewhere never stops sign-ups.
  • Forgot your password? — the starter's page asks for an email address and sends a one-time reset link (one hour).
  • Magic links — built into the starter: a one-time sign-in link by email, valid for 15 minutes. Not offered in the console yet — the switch returns when app email is live enough to deliver it.
  • Confirm the email before sign-in — also built in, off by default: a new account gets a confirmation link (24 hours) and cannot sign in until it is opened. Its switch is hidden for now — a confirmation the app cannot deliver only locks people out.
  • Passkeys are on by default — a fingerprint, face or security key signs a user in, scoped to your app's domain. Signed-in users add, name and remove their passkeys in the starter's Security section; turn passkeys off on the Providers tab.
  • Two-factor codes from an authenticator app — a switch on the Providers tab.
  • Sign-in attempts are limited per IP address, not per account: the edge allows 10 a minute on /api/auth/sign-* (up to 20 when a burst lands on both of its gateways, see Rate limits), and the starter itself allows 20 sign-ins and 20 sign-ups a minute. Past that the next try gets 429 until the minute is up — the right password included, and for everyone behind the same address.
  • A bot check on sign-in, sign-up and password reset — one switch, no key to create. See below.
  • Sign-up rules — block temporary inboxes, free email or +tag aliases, or allow only your own domains.
  • Blocking a user — Ban on the People tab stops that person signing in and ends every session they have.
  • Claims — role, plan or your own fields stamped onto each session.
  • JWT templates — a token shaped for another service (Supabase, Hasura, your own API), signed by your app.
  • API keys for your users — one switch on the Providers tab and your app can mint, list, revoke and verify keys for its own API. See below.
  • Google, GitHub, Apple, Discord, Microsoft, LinkedIn, X, Facebook and any OpenID Connect provider, when you add the provider's OAuth client credentials on the Providers tab.
  • A users table you can query and join against — a user is a row, not a record in someone else's dashboard.

Your app serves the sign-in endpoints itself, under /api/auth on its own domain. Camplax supplies the pieces that make that safe: it injects BETTER_AUTH_SECRET (the signing secret), AUTH_PROVIDERS (every switch on the Users page, as one JSON value), the provider credentials you add, plus DATABASE_URL, CAMPLAX_URL and CAMPLAX_PROJECT_TOKEN. You never set these by hand; they arrive with every deploy like the rest of the Secrets.

The endpoints

All POST bodies are JSON. Successful sign-ins set an HTTP-only session cookie.

MethodPathWhat it does
POST/api/auth/sign-up/email{ name, email, password } — create an account
POST/api/auth/sign-in/email{ email, password } — sign in
POST/api/auth/sign-outSign out
GET/api/auth/get-sessionThe signed-in user and session, or null
POST/api/auth/request-password-reset{ email } — email a reset link
GET/api/auth/reset-password/<token>Where the reset link points; checks the token and opens your /reset-password page with it
POST/api/auth/reset-password{ token, newPassword } — set the new password and end every other session
POST/api/auth/sign-in/magic-link{ email, callbackURL? } — email a sign-in link (when magic links are on)
GET/api/auth/magic-link/verifySigns the person in. The emailed link opens your app's page first (/#finish-sign-in=…), which sends the person here when they press Sign in
GET/api/auth/verify-emailWhere the confirmation link points; confirms the address and signs the person in
POST/api/auth/send-verification-email{ email } — a new confirmation link, for someone signed in
POST/api/auth/tokenA JWT for the signed-in user — see JWT templates
GET/api/auth/jwksThe public keys that verify those tokens
POST/api/auth/delete-user{ password? } — delete the signed-in account and everything it owns. An account with a password must send it; one without (Google, passkey, email link) needs a session started in the last day
GET/api/auth-configWhat the sign-in page should show: whether the bot check is on (and where to fetch its puzzle), whether magic links and password reset by email are offered, which methods are on, and the "Continue with …" providers
GET/api/auth/human-checkA fresh bot-check puzzle, when the check is on (see Bot check)

Social sign-in uses /api/auth/sign-in/social ({ provider: "google" }), passkeys /api/auth/passkey/* and two-factor /api/auth/two-factor/*.

Emails from sign-in

Reset links, magic links and confirmation links go out through Camplax with the CAMPLAX_PROJECT_TOKEN your app already has, from the project's address on inbound.camplax.app — no mail key to create. They count toward your plan's monthly email allowance like any other mail your apps send (10,000 / 25,000 / 75,000 a month, see Billing), and toward the project's email sending limits.

  • When an email cannot be sent, the person is told — "We couldn't send the email. Try again in a few minutes." — instead of being sent to wait for mail that is not coming.
  • Nobody can use these forms to find out who has an account. Forgot-password emails the address either way: a reset link if there is an account, a note saying there is none if not. The page answers the same in both cases. With email confirmation on, signing up with an address that already has an account answers like a new sign-up, and the owner of that address gets an email about it instead.
  • One email per address per minute for reset and magic links, account or not, so the forms cannot be used to flood someone's inbox.
  • Mail scanners cannot spend a magic link. Many mail systems open every link in a message before the person sees it. The link in the email opens your app's own page with the token after #, which never reaches a server, and the page asks for one click before it signs in.
  • A magic link to an unconfirmed account removes any password set on it. Signing in by link proves the person owns the address; a password set before that proof could belong to someone who signed up with an address that isn't theirs. They can set a new one with Forgot password?.
  • A new confirmation link goes only to someone who has proved the password: signing in with an unconfirmed address sends a fresh one by itself.
  • Previews cannot send email. There, the reset and magic-link forms are hidden and the endpoints answer that email works on the live app; email confirmation is not required. In camplax dev the emails are printed to your terminal instead of sent, links included.

Signing in with Google, GitHub and others

Turn the provider on in the Providers tab, paste the client id and secret from the provider's developer console, and give the provider the redirect URL the tab shows — Copy puts it on your clipboard. It is your app's own address followed by:

ProviderRedirect path
Google, GitHub, Apple, Discord, Microsoft, LinkedIn, X, Facebook/api/auth/callback/<provider> — for example /api/auth/callback/google
Single sign-on (OIDC)/api/auth/oauth2/callback/oidc

Once a custom domain is live, the redirect URL uses it, because that is where your app's sign-in answers. Update it at the provider when you add or change the domain.

On previews: Google and GitHub

A preview gets a new address for every branch, so a provider can't be told about each one — and a preview never gets your production client secret. Camplax signs people in for it instead. Add one more redirect URL at the provider, once:

https://camplax.dev/v1/oauth-proxy/callback

The Providers tab shows it under For previews, add this one too. After that, "Sign in with Google" on any preview goes through Camplax: Camplax sends the person to Google with your production client, finishes the sign-in with the secret it already holds, and hands the preview only the person's id, email, name and picture. The preview never sees the client secret or Google's tokens, and nothing to set up per preview — the starter does it with the CAMPLAX_PROJECT_TOKEN Camplax already injected.

What keeps it safe: Camplax only ever sends people back to a live preview of the same app (<branch>--<slug>.camplax.app), the sign-in is single-use and expires in 10 minutes, and the code it hands back can only be redeemed once, by that same preview's own token, with the preview's PKCE verifier. A code meant for one preview is useless on another.

  • Google: add the URL under Authorized redirect URIs of the same OAuth client.
  • GitHub: a GitHub App takes up to ten callback URLs — add it there. A classic OAuth App takes only one callback, on one host, so with one of those GitHub sign-in stays on the live address.
  • Apple, Discord, Microsoft, LinkedIn, X, Facebook and single sign-on still work on the live address only.

Users who sign in on a preview are created in the preview's own database, never production's. Keep in mind that a preview runs whatever its branch contains: anyone who can push a branch gets a preview that receives the name and email of whoever signs in on it, and Google's consent screen shows your production app's name. Preview the branches you trust, and keep sign-in tests to your own team.

Where the data lives

Everything about your users — the user, session, account and verification tables — sits in your project's own Postgres database, the same one your tables are in. There is no third-party user pool to sync, and nothing about a user lives on a service you cannot query. When you delete the project, the identity data goes with it: the database is removed, and the one final copy Camplax keeps in case a project was deleted by mistake is deleted 30 days later.

How many people can sign in

Your plan includes a number of monthly active users: people who signed in to your apps that calendar month (UTC), counted across all your projects.

IndieProScale
250,000 a month500,000 a month1,000,000 a month
  • What counts. Each person counts once a month, however often they sign in. Signing in, or coming back with a session that renews, makes a person active. Test users don't count, and neither do previews.
  • How it is counted. The starter writes one small row per person per month into your app's own database (the plax_active_users table). Camplax reads how many rows there are once a day; it never reads who they are. The Usage page shows this month's count against your plan.
  • Past your plan, more active users come out of your account's prepaid credits: $50 per 100,000, bought 100,000 at a time before they are needed. You get an email the first time this happens in a month.
  • With no credits left, new sign-ups stop on all your apps. People who already have an account keep signing in, and nobody who comes back is ever turned away. A new person sees "This app isn't taking new sign-ups right now." You get an email, and the Users and Usage pages say so. Add credits on the Billing page and sign-ups open again within a few minutes; otherwise they open again on the 1st.

An app made from an older starter does not write these rows, so it is neither counted nor paused. To count it, copy src/active-users.ts and its migration (sql/migrations/*_active_users.sql) from the current starter and add the two session hooks and the sign-up check from its src/auth.ts.

The Users page

The console's Users page is five views of one subject — the people who signed in to your app (the people who work on the app are under Teams, a different list):

TabWhat it shows
PeopleEvery registered user — name, email, verified, plan, banned — with ban, unban, reset password and delete actions
SessionsWho is signed in right now: device, IP, last seen, expiry — sessions that change address mid-stream are flagged
ProvidersWhich sign-in methods are on, and where OAuth client credentials go
Test usersThrowaway accounts for trying your own sign-in — see below
ClaimsThe fields stamped onto each session when it is created

Blocking a user

Ban marks the user as banned in your app's database and deletes all their sessions in the same step, so they are signed out everywhere. Every way of signing in — password, magic link, passkey, Google and the rest — then answers 403 with the code BANNED_USER and "This account has been blocked. Contact the app's owner if you think this is a mistake." Unban lets them sign in again.

Apps made before this was added have no column to hold a ban, and the console says Ban is not available for them rather than writing a flag nothing reads.

Bot check

Turn on Bot check on the Providers tab and your app's sign-in, sign-up, magic-link and password-reset forms ask the browser to do a small piece of work first — a proof-of-work puzzle in the ALTCHA format. Nobody clicks anything or picks out traffic lights: the page starts on the puzzle when someone clicks into a form, and it is usually done before they finish typing. It takes about a tenth of a second on a laptop and up to a second or two on a slow phone. That is nothing to a person and a real cost to a bot trying thousands of passwords or sign-ups.

Your app runs the whole check itself. There is no key to create or paste, no third-party script on the page, nothing sent to Camplax or anyone else, and no cost:

  • The app makes the puzzle. GET /api/auth/human-check returns one, signed with a key the app derives from the sign-in secret Camplax already gives each environment. A puzzle from a preview does not work on your live app.
  • The browser solves it with public/human-check.js in the background, spread over the device's cores so the page never freezes, and sends the answer in the x-captcha-response header.
  • The app checks the answer: the signature, that it was solved, that it is under 10 minutes old, and that it has not been used before — each answer works once. Anything else is refused with 403, never waved through.
  • The switch takes effect when the app restarts with the new settings, which happens by itself a few seconds after you save.
  • camplax dev runs without it unless you add "captcha":true to AUTH_PROVIDERS in .env.local.
  • A custom frontend: GET /api/auth-config says whether the check is on and where to fetch the puzzle. The puzzle and answer follow the ALTCHA format, so the open-source ALTCHA widget works too.
  • Screen readers and keyboards: there is nothing to see or press. While the page waits on the puzzle, the form says "Checking that you're a person…" in a live region. Without JavaScript, the page says sign-in needs it.

A proof-of-work check slows bots down; it does not tell people from bots the way Cloudflare Turnstile tries to. Sign-in rate limits still apply on top of it.

Apps made before November 3, 2026 used Cloudflare Turnstile through Camplax for this check. They keep working as they are: their site key stays in place while the check is on, and Camplax keeps verifying their tokens. To move one to the new check, copy src/captcha.ts, its migration (sql/migrations/*_human_check_spent.sql, where each used answer is remembered so it is accepted once across every copy of your app), the bot-check lines of src/auth.ts and public/human-check.js from a new app made from the same starter.

Who can sign up

Under Who can sign up on the Providers tab:

RuleWhat it refuses
Block temporary inboxesThrowaway services like mailinator.com and guerrillamail.com (a built-in list of about 80)
Block free emailgmail.com, outlook.com, yahoo.com, icloud.com and other free mailboxes — for work-only apps
Block +tag aliasesada+1@acme.com-style addresses, one inbox posing as many accounts
Only these domainsAnything outside the domains you list (subdomains included)
Refused patternsA domain (spam.com), subdomains (*.spam.com), a whole domain (*@spam.com) or one address

The rules run inside your app before a user row is written, so a refused address gets a clear error — "Sign up with your work email address" — and never reaches your users table. They apply to magic-link and social sign-ups too. Existing users are not affected.

Claims

A claim is a field copied from the user's row onto each new session, so your app can check role or plan without another query. role and plan are there by default (with member and free when the row has no value); add your own on the Claims tab — up to 32, snake_case names, each reading a column of the user table. A test user's password can never be read into a claim.

  • GET /api/auth/get-session returns them as JSON text in session.claims; the starter's sessionClaims(session) turns that into an object.
  • The default token from POST /api/auth/token includes them.
  • A claim is a snapshot. Change someone's role and their existing sessions keep the old value until they end; revoke the session on the Sessions tab to apply it now. For money, deletions or other people's data, check the database on the request.

JWT templates

Some services want their own JWT: Supabase row-level security, Hasura, a partner's API. Add a template under Claims → JWT templates — a name, the claims as JSON, and how long the token lasts (30 seconds to 24 hours, 15 minutes by default):

{
  "aud": "authenticated",
  "role": "authenticated",
  "email": "{{user.email}}"
}

Your app then signs one for whoever is signed in:

curl -X POST "https://my-app.camplax.app/api/auth/token?template=supabase" --cookie "…session cookie…"
# → { "token": "eyJhbGciOiJFZERTQSIs…" }
  • Shortcodes fill values from the session: {{user.id}}, {{user.email}}, {{user.name}}, {{user.emailVerified}}, {{user.image}}, {{user.createdAt}}, {{user.updatedAt}}, {{session.id}}, {{session.createdAt}}, {{session.expiresAt}}. A value that is exactly one shortcode keeps its type (true, a number of seconds for dates); inside longer text it is joined as text. Nothing else on the user row can reach a token.
  • Camplax sets sub (the user id), iss (your app's URL), iat and exp; a template cannot override them. It can set aud.
  • Adding a custom domain moves them. Once a custom domain is active, iss and the keys URL use that domain instead of my-app.camplax.app. Update the issuer and keys URL you gave the other service when you add one. Users → Settings → JWT templates always shows the current values.
  • Verify with the public keys at https://my-app.camplax.app/api/auth/jwks (EdDSA, Ed25519). The private key never leaves your app's database, encrypted with its BETTER_AUTH_SECRET — there is no shared secret to hand to the other service.
  • With no template, POST /api/auth/token returns a short default token: email, name, email_verified and the session's claims.

API keys for your users

Turn on API keys on the Providers tab and your app can hand its own users keys for its own API — the shape services like Unkey sell, served by your app's sign-in. Keys are stored hashed in an apikey table next to user; the raw key is shown once at mint and never again.

A signed-in user mints and manages keys through your app's own endpoints:

curl -X POST "https://my-app.camplax.app/api/auth/api-key/create" \
  --cookie "…session cookie…" \
  -H "Content-Type: application/json" \
  -d '{"name": "ci", "expiresIn": 2592000}'
# → { "key": "…shown once…", "id": "…", … }
MethodPathWhat it does
POST/api/auth/api-key/createMint a key for the signed-in user — name, expiresIn (seconds), optional metadata and permissions
GET/api/auth/api-key/listThe user's keys
POST/api/auth/api-key/get · /update · /deleteRead, edit or revoke one key

Callers then present the key as the x-api-key header on any route that wants a signed-in user — Plax auth: true routes accept it with no app code, because the session lookup resolves the key to its owner:

curl "https://my-app.camplax.app/api/notes" -H "x-api-key: …"
  • The key acts as the user that minted it — a row that user cannot reach is still a 404, and deleting the user kills their keys.
  • A key only reaches your app's own routes. Minting, listing or revoking keys, and every other account change, needs a real sign-in — so a leaked key cannot mint its own replacement.
  • Blocking a user with Ban stops their keys too; they work again if the ban is lifted or runs out.
  • Inside the app, auth.api.verifyApiKey({ body: { key } }) answers { valid, key } without a request — for cron and other server-side checks.
  • Rate limits are off by default so the app sets its own; where you want them, mint or update keys with rateLimitEnabled, rateLimitTimeWindow and rateLimitMax.

Sessions

Sessions use HTTP-only cookies with rotation, so a stolen laptop is a revoked session in the console, not a breach. A password reset ends every session the user had.

Test users

For previews and poking at your own app, Test users generates up to 25 accounts at once — names, *@test.camplax.app addresses (mail never arrives), a free/pro plan mix, and a password you can sign in with. Test users are marked as test accounts in the table and hidden from the People list unless you ask for them; one click clears them, their sessions and their accounts together. Because a test user is a real account with a password Camplax knows, only a person makes them — in the console, with the CLI, or through your own MCP connection — and each batch is recorded in the team's audit log. A project key can list (without passwords) and clear test users, but not make them.

Managing users from code

Everything the page does is on the API under /v1/projects/:slug/users. Reads need a session or a project key with users:read; writes need users:admin (and destructive console actions are owner-only).

MethodPathWhat it does
GET/usersList users (up to 100; ?include_test=1 includes test accounts)
GET/users/statsTotal, banned and verified counts
GET/users/export.csvDownload the user list as CSV
GET/users/sessionsLive sessions with device, IP, claims and flags
DELETE/users/sessions/:idRevoke one session
POST/users/sessions/revoke-allRevoke every session
GET/PUT/users/providersRead/set which sign-in methods are on — captcha: true turns on the bot check. The reply includes redirectBase, each provider's redirectPath, and for Google and GitHub the previewRedirectUrl previews sign in through
GET/PUT/users/signup-blocklistRead/set who can sign up: entries (refused patterns) and policy (blockDisposable, blockFreeProviders, blockAliases, allowedDomains). The reply's signupsPaused is true while new sign-ups are paused past your plan's active users
GET/PUT/users/jwt-templatesRead/set JWT templates; the reply includes your app's jwksUrl and tokenUrl
GET/PUT/users/claimsRead/set session claim definitions
GET/POST/DELETE/users/test-usersList, generate or clear test accounts
PATCH/users/:idChange a user's name, plan or role
POST/users/:id/ban / /users/:id/unbanBlock a user and end their sessions, or let them back in
POST/users/:id/reset-passwordEmail the user a one-time reset link (30 minutes), sent through the project's own email. The link opens /reset-password in your app
DELETE/users/:idDelete the user, their sessions and their accounts

Sign-in policy writes — providers, sign-up rules, JWT templates, claims, roles — need a signed-in console session; a project key can read them but not change them.

Provider credentials are secrets, not settings — when you turn on Google, GitHub or Apple, the client id and secret are stored as AUTH_GOOGLE_CLIENT_ID/AUTH_GOOGLE_CLIENT_SECRET (and friends) in the vault, where Secrets rules apply.

Rows stay with their owners

Sign-in is what makes "your data" mean something. In the starter — and any Plax app — defineResource takes an ownerField, and every read, update and delete it serves is scoped to the signed-in user's id inside the query itself, not checked afterwards. The client cannot write that field; the server sets it from the session, and a row you cannot see is a 404, not an error.

The sign-in page

The starter's page (public/index.html in the Plax starter, public/auth.js in the chatbot) has every form: sign-up, sign-in, passkeys, two-factor, magic link, forgot password and the reset page at /reset-password. It is plain HTML meant to be replaced — build your own against the endpoints above, and read GET /api/auth-config to know which forms to show.

The chatbot's page asks for the email first, then the password or a link by email, and offers saved passkeys right in the email field. Its Settings → Security lets each person change their password, add and remove passkeys, turn on two-step sign-in (with backup codes), connect Google or GitHub, see and sign out their devices, and delete their account.

Where the users go next

Sign-in is the same project as the database and storage, so "which files are theirs?" is a column and a rule, not an API call to another vendor. See File storage.

Under the hood: sign-in is Better Auth against your project's own database — no third-party user pool.

Camplax

Hosting, database, sign-in and storage in one project. One login, one bill.

Platform
More
Legal
Company
© 2026 Camplax From $25 / month · credits stop at zero