Skip to content

Sign-in for your app’s users

Your app’s users sign up and sign in at /auth/* on your app’s own address. Flatstack runs Better Auth inside your project and keeps users, sessions and passwords in your project’s own database, in tables named fs_auth_*. Your functions, routes, the data API and storage see the signed-in user as ctx.user, and the owner and authenticated access rules check it.

Every environment gets sign-in on its next deploy. Nothing to install.

Ways to sign in

Method Endpoints Notes
Email and password POST /auth/sign-up/email, POST /auth/sign-in/email Passwords are hashed with scrypt. 10 wrong passwords in a row lock password sign-in for 30 minutes (below).
Magic link POST /auth/sign-in/magic-link Emails a link that works once, for 60 minutes.
One-time code POST /auth/email-otp/send-verification-otp, POST /auth/sign-in/email-otp A 6-digit code, for 10 minutes.
Google, GitHub, Apple, Microsoft POST /auth/sign-in/social with { provider } Add your own OAuth app’s client ID and secret in Auth settings. Redirect URI: https://<your app>/auth/callback/<provider>. Preview environments can use Flatstack’s Google and GitHub apps until you add your own.
Passkeys /auth/passkey/* For your app’s own hostname.

Turn methods on and off, require email verification, and add providers in the dashboard (Project → Auth settings), with flatstack auth set and flatstack auth providers set (CLI), with the MCP tools get_auth_settings and set_auth_settings (MCP), or with PUT /v1/projects/:projectId/auth/settings (API). Changes reach your app on its next deploy, like environment variables.

GET /auth/fs/config tells your sign-in screen which methods and providers are on, and the Turnstile site key. It never includes a secret.

Sessions

Sign-in sets an HTTP-only cookie on your app’s domain. Sessions last 30 days and extend when used, once a day. POST /auth/sign-out ends one.

Mobile apps can’t use cookies well, so:

  1. Sign in as usual. The response has a set-auth-token header: the session token.
  2. GET /auth/token with Authorization: Bearer <session token> returns { token }, a JWT that works for 15 minutes.
  3. Call your functions with Authorization: Bearer <JWT>. Get a new one from /auth/token when it expires.

The JWT’s keys are at GET /auth/jwks if another service needs to check it.

Email verification, password reset, account changes

Action Endpoint
Resend verification POST /auth/send-verification-email
Ask for a reset link POST /auth/request-password-reset with { email, redirectTo }
Set the new password POST /auth/reset-password with { token, newPassword }
Change email POST /auth/change-email
Change password POST /auth/change-password
Delete own account POST /auth/delete-user

Reset links land on your app’s reset page (/reset-password unless you set another) with ?token=. When verification is required, sign-in before it answers 403 email_not_verified.

Auth emails come from your project’s email sender and keep sending up to 110% of your email limit. Replace any of them with one of your own templates in Auth settings (verification, passwordReset, magicLink, otp, changeEmail, invitation); the template gets url (or otp) and appName.

Roles and organisations

Every user has a role: user, unless you give them another. Declare your own roles in flatstack/auth.ts:

import { defineAuth } from "@flatstackdev/server/auth";

export const auth = defineAuth({
  roles: ["editor", "billing"],
  hooks: {
    afterSignUp: async ({ user, ctx }) => {
      await ctx.db.insert(profiles).values({ userId: user.id });
    },
  },
});

admin is built in: functions with auth: "admin" need it. Set roles from the dashboard, with PATCH /v1/projects/:projectId/auth/users/:userId, or from your app’s own admin screens with /auth/admin/set-role.

Users can create organisations, invite people by email and give them a role in each: owner, admin or member (/auth/organization/*). The user’s active organisation is ctx.org ({ id, slug, name, role }, or null), and access rules get it too:

export const notes = table("notes", { id: text().primaryKey(), orgId: text().notNull(), body: text() }, {
  access: {
    read: ({ org }, row) => org !== null && row.orgId === org.id,
    write: ({ org }, row) => org?.role !== "member" && row.orgId === org?.id,
  },
});

Turn off creating organisations with defineAuth({ organizations: { allowCreation: false } }).

Limits and protection

What Limit What happens
Wrong passwords for one account From the 3rd in a row, each try waits twice as long (1 s, 2 s, 4 s…). The 10th locks password sign-in for 30 minutes. 429 sign_in_backoff or 429 sign_in_locked, with Retry-After. A password reset or an admin’s Unlock clears it. Magic links, codes and passkeys still work.
Sign-in attempts per IP address 10 a minute 429 rate_limited
Sign-ups per IP address 5 a minute 429 rate_limited
Emails per IP address (links, codes, resets) 3 a minute 429 rate_limited
Monthly active users Your plan’s MAU limit At 100%, new sign-ups by any method get 403 mau_capped. Existing users keep signing in.

A user counts towards MAU once per billing period, the first time they sign in or their session refreshes. Flatstack is told only a hash of your project and user ID, never who they are.

/auth/* keeps working when your account is at its request limit, read-only after a failed payment, or cancelled and in its grace period, so your users can still sign in and see their data.

Bot protection

Turn on Cloudflare Turnstile in Auth settings with your site key and secret key. Sign-up, password sign-in, magic links and one-time codes then need the widget’s token in an x-captcha-response header, and answer 403 bot_check_failed without a valid one.

Admin

From Project → Users in the dashboard, or the API: find users by email, name or ID; ban and unban (a ban signs them out everywhere); sign a user out of every device; unlock password sign-in; send a password reset email; change their role.

Impersonate opens your app signed in as the user, for an hour, so you can see what they see. Only a signed-in owner or admin can do it. The link works once, for 5 minutes, and is in the audit log. While it lasts, every page your app renders through Flatstack has a banner saying who you’re signed in as, with Stop impersonating. Static pages get the banner from /auth/fs/banner.js. Your app’s own admins can’t impersonate users from inside the app: /auth/admin/impersonate-user answers 403.

Users imported from elsewhere

Users brought over from Supabase and similar services keep their passwords: bcrypt hashes are checked as they are, and replaced with scrypt the first time each user signs in.

Errors

Every /auth error uses Flatstack’s shape: { "error": { "code", "message", "docsUrl", "details"? } }.

Code Status When
invalid_credentials 401 Wrong email or password.
email_not_verified 403 Verification is required and the email isn’t verified.
email_taken 409 Signing up with an email that already has an account.
invalid_token 400 An expired or used link or code.
sign_in_backoff, sign_in_locked 429 Failed passwords (above).
rate_limited 429 Over a per-IP limit.
bot_check_failed 403 Turnstile is on and the token is missing or wrong.
user_banned 403 The user is banned.
mau_capped 403 A new sign-up at 100% of MAU.
auth_not_deployed 501 The environment hasn’t been deployed since sign-in arrived. Deploy it again.

Other Better Auth errors keep their message, with Better Auth’s own code in details.reason.

Local development

flatstack dev serves /auth/* as a deployed environment does. It creates the fs_auth_* tables in the local database before your migrations, runs your roles and hooks from flatstack/auth.ts, and uses the settings a new project starts with: email and password, magic links, one-time codes and passkeys on, and verification optional. Dashboard settings, such as OAuth providers and Turnstile, apply to deployed environments only. The key that signs local sessions is kept in .flatstack/dev/auth-secret.

It captures auth emails instead of sending them. GET /auth/fs/dev/outbox?to=<email> lists them, newest first, for an hour. Deployed environments don’t have this.

As Markdown: apps/auth.md