Platform

Authentication

Sign in your app's users with email and password, email or SMS codes, social providers, passkeys, and TOTP two-factor, using project-scoped JWTs.

Every project has its own user directory. Your app's users sign up and sign in through the project's auth endpoints, and each successful sign-in returns a short-lived access token and a refresh token, both JWTs signed with the project's secret. These users are separate from the Studio accounts your team uses to manage the project.

How it works

  • Users live in auth_* tables inside the project's own database (auth_users, auth_sessions, auth_identities, auth_mfa_factors, auth_passkey_credentials, auth_audit_log and others).
  • All end-user auth endpoints live under https://api.orbitnest.io/api/projects/<slug>/auth/. The project can be addressed by slug or UUID. The public endpoints (sign-up, sign-in, refresh, password reset) need no API key. Endpoints that act on the current user need the user's access token in Authorization: Bearer.
  • A successful sign-in returns a session object: access_token, refresh_token, expires_in and user.
  • Tokens are HS256 JWTs signed with the project's JWT secret. Lifetimes are configurable. See API keys & JWT.
  • Banned users (auth_users.banned_until in the future) are refused everywhere: sign-in by any method, refresh, social and passkey sign-in, and every request that carries their access token.
MethodJavaScript / Node.js SDKFlutter SDKREST
Email and passwordYesYesYes
Email one-time codes (passwordless)NoYesYes
SMS one-time codesYesYesYes
Two-factor (TOTP)YesYesYes
PasskeysYes (you run the browser ceremony)YesYes
Social sign-in (Google, GitHub, Apple)NoNoBrowser redirect

The examples below use these clients:

orbit.ts
import { createClient, localStorageAdapter } from '@orbitneststudio/js';

export const orbit = createClient({
  apiKey: import.meta.env.VITE_ORBITNEST_ANON_KEY,
  storage: localStorageAdapter, // keep the session across page reloads (default: in memory)
});
Dart
await EnvConfig.initialize();
final orbitnest = OrbitNestClient.create();
final auth = orbitnest.auth;

The Node.js SDK (@orbitneststudio/node) has the same auth methods as the JavaScript SDK, without the storage option.

Email delivery and Test Mode

Verification codes, password-reset codes and the welcome email go out through the project's own SMTP server when one is configured in Project → Settings → SMTP. See Email & SMTP.

Without project SMTP, these auth emails are sent by the shared OrbitNest sender:

  • From "<App name> via OrbitNest", with a no-reply Reply-To address.
  • Built-in templates only. Your custom templates are used only with your own SMTP.
  • Limited to 100 emails per hour per project and 5 per hour per recipient. Over the limit, the request returns 429 with code SHARED_EMAIL_RATE_LIMITED.

Customize the messages with the otp_verification, password_reset and welcome templates. See Email templates.

Test Mode (Project → Settings → General) is for development. While it is on, no email is sent and every email code is 1234. It covers sign-up, email sign-in and password-reset codes. It does not affect SMS codes or TOTP.

Never leave Test Mode on in production

With Test Mode on, anyone can sign in as any existing user by requesting an email code and entering 1234. It also lets anyone reset any user's password.

Email and password

Sign up

Sign-up takes two steps. The account is only created after the user proves they own the address.

  1. signUp stores the hashed password and emails a code (6 digits by default, set by otp_config.code_length). The code is valid for 10 minutes by default.
  2. verifySignUp checks the code, creates the user, and returns a session.
TypeScript
const { error } = await orbit.auth.signUp({
  email: 'ada@example.com',
  password: 'correct-horse-battery',
  metadata: { full_name: 'Ada Lovelace' }, // stored as user_metadata
});

// The user enters the code from their inbox
const { data: session, error: verifyError } = await orbit.auth.verifySignUp({
  email: 'ada@example.com',
  code: '4821',
});
Dart
await auth.signUp(
  email: 'ada@example.com',
  password: 'correct-horse-battery',
  metadata: {'full_name': 'Ada Lovelace'},
);
final result = await auth.verifySignUp(email: 'ada@example.com', otp: '4821');
  • Signing up with an email that already has an account returns 409.
  • Requesting a new code replaces the previous unused one.
  • A welcome email is sent the first time an account is created.
  • Passwords must be 8 to 128 characters and are hashed with bcrypt. The API checks length only, so check password strength in your app.

Sign in

TypeScript
import { isMfaChallenge } from '@orbitneststudio/js';

const { data, error } = await orbit.auth.signIn({
  email: 'ada@example.com',
  password: 'correct-horse-battery',
});

if (isMfaChallenge(data)) {
  // The user has two-factor enabled. Ask for a code, then:
  await orbit.auth.verifyMfa(data.challenge_token, codeFromUser);
}
Dart
final res = await auth.signInWithPassword(
  email: 'ada@example.com',
  password: 'correct-horse-battery',
);
if (res['mfa_required'] == true) {
  await auth.verifyMfa(challengeToken: res['challenge_token'], code: code);
}

Wrong credentials return 401 Invalid credentials. An account created without a password (email code, SMS, passkey or social sign-in) returns 401 with a message telling the user to sign in with an email code instead. A banned account returns 401 Account is temporarily banned.

Reset a password

  1. Request a reset code. The response is identical whether or not the account exists, so the endpoint can't be used to discover registered emails.
  2. Submit the code with the new password (8 to 128 characters). This ends every session, and existing access tokens stop working at once.
TypeScript
await orbit.auth.resetPasswordForEmail({ email: 'ada@example.com' });

await orbit.auth.updatePassword({
  email: 'ada@example.com',
  token: '7310', // the emailed code
  password: 'new-long-passphrase',
});
Dart
await auth.resetPasswordForEmail('ada@example.com');
await auth.resetPassword(
  email: 'ada@example.com',
  token: '7310',
  newPassword: 'new-long-passphrase',
);

Codes are 4 digits and expire after 10 minutes.

Email one-time codes

Passwordless sign-in emails a code (6 digits by default, set by otp_config.code_length), valid for 10 minutes by default, instead of using a password. The Flutter SDK wraps these endpoints. In JavaScript, call them over REST.

Dart
// New user
await auth.signUpWithEmail('ada@example.com', metadata: {'full_name': 'Ada'});
await auth.verifySignUp(email: 'ada@example.com', otp: '4821');

// Returning user
await auth.signInWithEmail('ada@example.com');
await auth.verifySignIn(email: 'ada@example.com', otp: '5530');
Terminal
# Sign in: request a code, then verify it
curl -X POST https://api.orbitnest.io/api/projects/my-app/auth/signin-with-email \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com"}'

curl -X POST https://api.orbitnest.io/api/projects/my-app/auth/verify-signin \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com","code":"5530"}'
  • signup-with-email accepts optional user_metadata. verify-signup accepts an optional password (8–128 characters) if you want the account to have one.
  • For an unknown email, signin-with-email returns the same success response as a real send and emails nothing, so the endpoint can't be used to discover accounts. A banned account returns 401.

SMS one-time codes

Phone sign-in sends a 6-digit code through your own Twilio account. The user is created on their first successful sign-in.

Configure Twilio

In Project → Settings → SMS / Twilio, enter your Account SID, Auth Token and From number (E.164, for example +15555550123), check Enable phone OTP for this project, and save. Use Send a test SMS to check the setup. The auth token is write-only and never shown again. Only workspace admins can read or change the SMS settings, in Studio, the CLI or the API.

From a terminal: orbitnest sms set --sid <sid> --token <token> --from <number> --enable, then orbitnest sms test <phone>. See CLI.

Sign in with a phone number

TypeScript
await orbit.auth.signInWithSms('+15555550123');
const { data: session, error } = await orbit.auth.verifySmsOtp('+15555550123', '482913');
Dart
await auth.signInWithSms('+15555550123');
await auth.verifySmsOtp(phone: '+15555550123', code: '482913');
  • The message reads Your verification code is 123456. Codes expire after 10 minutes and work once. Requesting a new code invalidates any earlier one.
  • Phone numbers must be 7 to 15 digits. They are normalized to E.164 with a leading +.
  • Sending fails with 400 if SMS isn't configured and enabled, or if Twilio rejects the message.
  • Each phone number can receive 1 code per minute and 5 per hour. Past that, send-otp returns 429 with code SMS_RATE_LIMITED.
  • After otp_config.max_attempts wrong codes for a number (3 by default), verify-otp returns 429 with code OTP_RATE_LIMITED for 15 minutes from the first failure.
  • A new phone user gets a placeholder email of the form 15555550123@sms.orbitnest and a confirmed phone number.
  • Test Mode does not apply to SMS. Every code is real and sent through Twilio.

Two-factor authentication (TOTP)

Users can add an authenticator app (Google Authenticator, 1Password and others) as a second factor. Once a verified factor exists, email and password sign-in returns a challenge instead of a session. Email-code, SMS, passkey and social sign-in are not challenged.

Enroll

The user must be signed in. To add a second authenticator when one is already verified, pass a current code from an existing authenticator or a recovery code (see Manage factors).

TypeScript
const { data } = await orbit.auth.enrollMfaTotp('My phone');
// Show data.qr_code (a PNG data URL) or data.secret. data.otpauth_url is the raw URI.

const { data: confirmed } = await orbit.auth.verifyMfaEnrollment(data.factor_id, '123456');
confirmed.recovery_codes; // 10 single-use codes, shown only once
Dart
final enroll = await auth.enrollMfaTotp(friendlyName: 'My phone');
final confirmed = await auth.verifyMfaEnrollment(
  factorId: enroll['factor_id'],
  code: '123456',
);

The authenticator app lists the entry as OrbitNest with the user's email.

Complete a challenged sign-in

signIn returns { mfa_required: true, challenge_token, factors }. Pass the challenge_token and a code to verifyMfa within 5 minutes.

  • The code can come from any of the user's verified authenticators, or it can be a recovery code.
  • The challenge_token only works at verify-mfa. It is not an access token and is refused everywhere else.
  • After otp_config.max_attempts wrong codes (3 by default), verify-mfa returns 429 with code OTP_RATE_LIMITED for 15 minutes from the first failure.
  • A banned user gets 401 even with a correct code.

Recovery codes

When an enrollment is confirmed and the user has no unused recovery codes, the response includes 10 single-use codes in the form abcd-efgh. If unused codes already exist, recovery_codes is an empty array and the existing codes stay valid.

Each recovery code works once in place of an authenticator code, at verify-mfa or as the step-up code below. Send it with or without the hyphen. Matching ignores case and formatting.

regenerateMfaRecoveryCodes(code) replaces the whole set and returns { codes } once. It needs a verified factor.

Manage factors

Once the user has a verified authenticator, these actions need a current authenticator code or a recovery code as code:

  • enrolling another authenticator (POST /mfa/totp/enroll)
  • removing a factor (DELETE /mfa/factors/:id, with code in the body or as ?code=)
  • regenerating recovery codes (POST /mfa/recovery-codes)

Without a code they return 401 with code MFA_REAUTH_REQUIRED. A wrong code returns 401, and otp_config.max_attempts wrong codes return 429 with code OTP_RATE_LIMITED.

TypeScript
const { data: factors } = await orbit.auth.listMfaFactors(); // id, factor_type, friendly_name, verified, created_at, last_used_at
await orbit.auth.unenrollMfa(factorId, '123456');
await orbit.auth.enrollMfaTotp('Backup phone', '123456');
const { data: fresh } = await orbit.auth.regenerateMfaRecoveryCodes('123456');
Dart
final factors = await auth.listMfaFactors();
await auth.unenrollMfa(factorId: factorId, code: '123456');
await auth.enrollMfaTotp(friendlyName: 'Backup phone', code: '123456');
final fresh = await auth.regenerateMfaRecoveryCodes(code: '123456');

The code parameters are in @orbitneststudio/js 0.7.0, @orbitneststudio/node 0.13.0 and the Flutter SDK 1.7.0.

Passkeys

Passkeys (WebAuthn) let users sign up and sign in with Face ID, Touch ID, a device PIN or a security key. User verification is required, and every ceremony challenge expires after 5 minutes.

Set up your app domain

Passkeys belong to a domain, the relying-party ID. In Project → Settings → Passkeys, enter your App domain as a bare hostname such as app.example.com (no https://, port or path) and save. Only workspace admins can change it.

OrbitNest then accepts passkey responses from:

  • https://<app domain>, which covers your website and iOS apps.
  • Every Android signing key listed in https://<app domain>/.well-known/assetlinks.json. Each sha256_cert_fingerprints entry of an android_app statement with the delegate_permission/common.get_login_creds (or handle_all_urls) relation becomes an android:apk-key-hash: origin.
  • Any Extra origins you add under the advanced field, one per line and up to 20: https://host[:port] or android:apk-key-hash:<base64url>. Use it for keys your assetlinks.json can't list, such as a local debug signing key. Wildcards aren't accepted.

Host these two files at the app domain:

FileRequirements
/.well-known/apple-app-site-associationHTTP 200, Content-Type: application/json, no redirect, no .json extension. Contains "webcredentials": { "apps": ["<TEAMID>.<bundleId>"] }.
/.well-known/assetlinks.jsonHTTP 200, Content-Type: application/json, a JSON array with a delegate_permission/common.get_login_creds statement listing your Android signing SHA-256 fingerprints.

Click Check association files to fetch both files and see what's wrong with each, plus the full list of origins OrbitNest will accept. OrbitNest caches assetlinks.json for an hour, so a removed fingerprint stops working within that time. Running the check refreshes the cache right away.

If a passkey response comes from an origin that isn't allowed, the verify call returns 400 naming the rejected origin. Add it to assetlinks.json or to Extra origins.

Projects without an app domain

Until you set an app domain, passkeys use the relying party configured on the OrbitNest server (orbitnest.io on the hosted platform). Changing the app domain later invalidates passkeys created under the old one.

JavaScript

The JavaScript and Node.js SDKs make the server calls. Your app runs the browser ceremony itself, for example with @simplewebauthn/browser:

TypeScript
import { startAuthentication, startRegistration } from '@simplewebauthn/browser';

// Sign in: the user picks one of their passkeys for this site
const { data: opts } = await orbit.auth.signInWithPasskeyOptions();
const assertion = await startAuthentication({ optionsJSON: opts.publicKey });
const { data: session } = await orbit.auth.signInWithPasskeyVerify({
  challenge_id: opts.challenge_id,
  assertion,
});

// Add a passkey to the signed-in user
const { data: reg } = await orbit.auth.registerPasskeyOptions({ device_name: 'MacBook' });
const attestation = await startRegistration({ optionsJSON: reg.publicKey });
await orbit.auth.registerPasskeyVerify({ challenge_id: reg.challenge_id, attestation });

The sign-in options never list a user's credentials. An identifier you pass is accepted but ignored, so the response doesn't reveal whether an account exists.

To create a new account with a passkey, use signUpWithPasskeyOptions({ email, user_metadata, device_name }) and then signUpWithPasskeyVerify({ challenge_id, attestation }). Set user_metadata.full_name so the native passkey sheet shows a name instead of the email.

  • If any account already uses the email, the options call returns 409. Sign the user in another way, then add a passkey with registerPasskeyOptions.
  • The one exception is an abandoned passkey sign-up for that email (never completed, never signed in, no password), which can be retried.
  • signUpWithPasskeyVerify returns 409 if the sign-up was already completed or claimed by another attempt.

Manage devices with listPasskeys(), renamePasskey(id, name) and revokePasskey(id).

Flutter

The Flutter SDK runs the native ceremony (Credential Manager on Android, ASAuthorization on iOS):

Dart
if (await auth.isPasskeySupported()) {
  await auth.signUpWithPasskey(email: 'ada@example.com', userMetadata: {'full_name': 'Ada'});
  // later
  await auth.signInWithPasskey();
}

await auth.registerPasskey(deviceName: 'Pixel 9'); // signed-in user
final devices = await auth.listPasskeys();
await auth.renamePasskey(deviceId: devices.first.id, deviceName: 'Work phone');
await auth.revokePasskey(deviceId: devices.first.id);

Social sign-in (Google, GitHub, Apple)

End users can sign in with Google, GitHub or Apple using a browser redirect flow. None of the SDKs wrap this flow. You start it with a plain link and read the tokens from the redirect.

Configure a provider

  1. Open Project → Settings → Social Login and choose a provider.
  2. Copy the Callback URL, https://api.orbitnest.io/api/projects/<slug>/auth/oauth/<provider>/callback, into the provider's OAuth app settings.
  3. Enter the Client ID and Client secret. For Apple, also enter your Team ID, Key ID, and the .p8 private key.
  4. Set the Post sign-in redirect URL. This is the page in your app that receives the result, for example https://myapp.com/auth/callback. It must be an http or https URL.
  5. Turn on Enabled for users and save.

Secrets are stored encrypted and never returned. Leave the secret blank when editing to keep the stored one. Only workspace admins can read or change provider settings.

Start the flow

Send the browser to the start URL:

text
https://api.orbitnest.io/api/projects/my-app/auth/oauth/google/start

Add ?redirect_to=<url> to land on a different page. It is only honored if it has the same origin as the configured redirect URL. The flow uses PKCE.

Handle the redirect

On success the browser lands on your redirect URL with the tokens in the URL fragment, so they never reach server logs:

text
https://myapp.com/auth/callback#access_token=...&refresh_token=...&expires_in=900&token_type=Bearer&action=signin&provider=google
auth/callback.ts
const hash = new URLSearchParams(window.location.hash.slice(1));
const accessToken = hash.get('access_token');

if (accessToken) {
  const res = await fetch('https://api.orbitnest.io/api/projects/my-app/auth/user', {
    headers: { Authorization: `Bearer ${accessToken}` },
  });
  orbit.auth.setSession({
    access_token: accessToken,
    refresh_token: hash.get('refresh_token')!,
    expires_in: Number(hash.get('expires_in')),
    token_type: 'Bearer',
    user: await res.json(),
  });
  history.replaceState(null, '', window.location.pathname); // remove tokens from the URL
}

On failure the browser lands on the same URL with query parameters oauth_error, provider and action. If the provider has no configuration at all, it is sent to the API's base URL instead. Possible oauth_error values: provider_not_configured, provider_disabled, provider_error, missing_state, invalid_state, no_email, email_unverified, link_conflict, upsert_failed, account_banned, token_issue_failed.

The /oauth/* endpoints accept 60 requests per minute per IP address.

How accounts are matched

  1. If the provider account is already linked to a user, that user is signed in.
  2. Otherwise, if a user exists with the same email, the provider is linked to that user. The provider must report the email as verified. For Apple, the email is read only from Apple's signed ID token, never from the name and email fields Apple posts back with the form.
  3. Otherwise a new user is created without a password. Their user_metadata gets full_name, avatar_url and provider.

For a signed-in user, call POST /auth/oauth/<provider>/link/prepare with their access token and an optional { "redirect_to": "..." }. It returns { "start_url": "..." }. Send the browser there and the redirect comes back with action=link. A provider account that is already linked to a different user fails with link_conflict.

GET /auth/oauth/identities lists linked providers. DELETE /auth/oauth/identities/<provider> unlinks one. Unlinking is refused if it would leave the user with no password and no other linked provider.

Sessions and tokens

Every successful sign-in, verification or refresh returns:

JSON
{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIs...",
  "expires_in": 900,
  "user": {
    "id": "9f2a1c7e-5b1d-4a7e-9c33-2f0e6a1b8d40",
    "email": "ada@example.com",
    "email_confirmed_at": "2026-10-01T09:30:00.000Z",
    "phone": null,
    "phone_confirmed_at": null,
    "user_metadata": { "full_name": "Ada Lovelace" },
    "app_metadata": {},
    "role": "authenticated",
    "created_at": "2026-10-01T09:30:00.000Z",
    "updated_at": "2026-10-01T09:30:00.000Z",
    "last_sign_in_at": "2026-10-01T09:30:00.000Z"
  }
}

Access token claims

JSON
{
  "sub": "9f2a1c7e-5b1d-4a7e-9c33-2f0e6a1b8d40",
  "email": "ada@example.com",
  "projectId": "my-app",
  "role": "authenticated",
  "iss": "orbitnest:project:my-app",
  "aud": "my-app",
  "jti": "1b7e6f0a-...",
  "type": "access",
  "iat": 1790000000,
  "exp": 1790000900
}

aud is the project slug and iss is orbitnest:project:<slug>. projectId holds whichever identifier, slug or UUID, the project was addressed by when the token was issued. User metadata is not included in the token.

Only access tokens work as a bearer token. Refresh tokens (type: "refresh") and MFA challenge tokens are refused on the auth endpoints, the database API, storage, realtime and edge functions.

Lifetimes

Access tokens last 900 seconds (15 minutes) and refresh tokens last 604,800 seconds (7 days) by default. Change both in API Keys → JWT Settings. See API keys & JWT.

Refreshing

Each refresh returns a new access token and a new refresh token. The old refresh token stops working, and reusing it returns 401. If two refreshes race with the same token, only one succeeds.

If an already-used refresh token is presented again more than 10 seconds after it was rotated, OrbitNest treats it as stolen and ends every session of that user.

  • JavaScript / Node.js (@orbitneststudio/js 0.7.0, @orbitneststudio/node 0.13.0): the SDK refreshes the session automatically within 30 seconds of expiry, before it sends a request. You can also call orbit.auth.refreshSession() yourself. Read the session with orbit.auth.getSession(), the user with orbit.auth.getUser(), and listen for changes with orbit.auth.onAuthStateChange(cb).
  • Flutter: the SDK refreshes on a 401 and retries the request. Listen to auth.onAuthStateChange for state changes.

Signing out

  • signOut() deletes the current session. Its refresh token and its access token stop working at once.
  • POST /auth/signout-all with the user's access token deletes every session for that user. No SDK wraps it.
  • Resetting a password deletes all of the user's sessions.
  • Regenerating the project's JWT secret invalidates every access and refresh token at once.

Revocation is immediate on the auth endpoints, the database API, storage and new realtime connections. It applies to tokens OrbitNest issued. Tokens you mint yourself with the project's JWT secret are checked by signature only.

Verifying tokens on your server

Call GET /auth/verify with Authorization: Bearer <access_token>. It returns { valid, user: { id, email, role, aud, exp, iat } } or { valid: false, error }. It also returns valid: false for a signed-out session or a banned user.

You can also verify locally with the project's JWT secret. See API keys & JWT. A local check sees only the signature and expiry, so also reject tokens that have a type other than access, and remember that it can't see sign-outs or bans.

Users and profiles

TypeScript
const { data: user } = await orbit.auth.getProfile(); // GET /auth/user

await orbit.auth.updateUser({ metadata: { full_name: 'Ada King' } }); // PUT /auth/user

await orbit.auth.updateUser({
  password: 'new-long-passphrase',
  current_password: 'correct-horse-battery',
});
Dart
await auth.updateUser(metadata: {'full_name': 'Ada King'});
await auth.updateUser(
  password: 'new-long-passphrase',
  currentPassword: 'correct-horse-battery',
);

PUT /auth/user accepts email, password (8 to 128 characters), current_password and data, an object merged into user_metadata. The SDKs send their metadata option as data.

  • Changing the password or email needs proof. Send the current password as current_password. Accounts without a password (email code, SMS, passkey or social sign-in) instead need an access token issued in the last 10 minutes, so ask the user to sign in again first. Otherwise the request returns 401 with code REAUTH_REQUIRED.
  • Email changes are confirmed by code. A new email doesn't take effect right away. The API emails a one-time code to the new address and returns the user with the old email plus new_email. Confirm with POST /auth/user/email/verify { "code": "…" } (verifyEmailChange(code) in the JavaScript and Node.js SDKs). The email then changes and is marked confirmed. See Change the email address.
  • Metadata-only updates need no extra proof.

current_password and the metadata mapping are in @orbitneststudio/js 0.7.0, @orbitneststudio/node 0.13.0 and the Flutter SDK 1.7.0 (currentPassword:).

Terminal
curl -X PUT https://api.orbitnest.io/api/projects/my-app/auth/user \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"ada.king@example.com","current_password":"correct-horse-battery"}'

Change the email address

TypeScript
// 1. Re-authenticate and request the change: a code goes to the NEW address
const { data: user } = await orbit.auth.updateUser({
  email: 'ada.king@example.com',
  current_password: 'correct-horse-battery',
});
// user.email is still the old address; user.new_email === 'ada.king@example.com'

// 2. Confirm with the code from that inbox
await orbit.auth.verifyEmailChange('4821');
  • An address that another user already has returns 409, both when you request the change and when you confirm it.
  • One code per user per minute. A second request inside that minute returns 429 with code EMAIL_CHANGE_RATE_LIMITED. A new request replaces the pending address.
  • The code expires after 10 minutes by default. otp_config.max_attempts wrong codes invalidate it and return 429 with code OTP_RATE_LIMITED. Only the user who requested the change can use the code.
  • With the project's debug mode on, no email is sent and the code is 1234.
  • The old address is not notified, and existing sessions stay signed in. The access token keeps the old email claim until it is refreshed.
  • verifyEmailChange is in the JavaScript and Node.js SDKs. The Flutter SDK has no wrapper yet, so call the endpoint directly.

Delete the current user

DELETE /auth/user, wrapped by deleteUser() in every SDK, lets a signed-in user delete their own account:

TypeScript
await orbit.auth.deleteUser(); // clears the local session on success
Dart
await auth.deleteUser();
  • The user row is kept but scrubbed: the email becomes deleted-<id>@deleted.invalid, and the phone, password and metadata are cleared. The original email can sign up again.
  • The user's sessions, linked providers, MFA factors, recovery codes and passkeys are deleted.
  • Only the user's own access token works. Studio admin tokens and project API keys get 403. To delete another user from your server, use auth.admin.deleteUser(id).
  • Rows in your own tables that reference the user are not touched.

Manage users from your server

The /auth/admin/* endpoints manage any user of the project. They accept only the project's current service role key, sent as the apikey header (or x-api-key, or Authorization: Bearer). Anon keys, rotated keys, user tokens and Studio tokens are refused with 401 or 403. Never ship the service role key to a browser or app.

TypeScript
import { createClient } from '@orbitneststudio/node';

const admin = createClient({ apiKey: process.env.ORBITNEST_SERVICE_ROLE_KEY! });

const { data: page } = await admin.auth.admin.listUsers({ page: 1, limit: 50, search: '@example.com' });
const { data: user } = await admin.auth.admin.createUser({
  email: 'ada@example.com',
  password: 'a-long-passphrase', // omit for a passwordless user
  email_confirm: true,
});
await admin.auth.admin.updateUserById(user!.id, { banned_until: '2030-01-01T00:00:00Z' }); // ban
await admin.auth.admin.updateUserById(user!.id, { banned_until: null });                   // unban
await admin.auth.admin.deleteUser(user!.id);
const { data: stats } = await admin.auth.admin.getStats();
Method and pathBody or queryResult
GET /auth/admin/users?page=1&limit=50&search= (limit up to 1000; search matches part of the email){ users, total, page, limit, total_pages }, newest first
GET /auth/admin/users/:id{ user }
POST /auth/admin/usersemail, optional password (8 to 128 characters), email_confirm, user_metadata, app_metadata{ user }, or 409 if the email is taken
PUT /auth/admin/users/:idAny of email, password, email_confirm, user_metadata, app_metadata, banned_until{ user }
DELETE /auth/admin/users/:id{ success, message }
GET /auth/admin/stats{ total_users, active_users, verified_users, recent_signups, recent_signins } (recent = last 7 days)
  • Users returned here also carry app_metadata, banned_until and new_email. Deleted users are not listed.
  • PUT merges user_metadata and app_metadata key by key. An email set here applies at once, without a code, and is unconfirmed unless email_confirm is true.
  • A banned_until in the future, or a new password, signs the user out of every session. Banned users can't sign in, refresh or use their tokens until that time. banned_until: null lifts the ban.
  • DELETE does the same scrub as deleteUser().
  • Without a password, createUser makes a passwordless user who signs in with an email code, a social provider or a passkey. No invitation email is sent.
  • These endpoints allow 120 requests per minute per IP. auth.admin is only in @orbitneststudio/node. The browser SDK leaves it out on purpose.

Managing users in Studio

Project → Auth lists the project's auth tables. Open a table to browse its rows: users, sessions, identities, MFA factors, passkeys and the audit log. You can edit or delete single rows there, for example a user's auth_sessions rows to sign them out. This page only works for the member who created the project.

To ban a user, use auth.admin.updateUserById, which also signs them out. Any workspace member can also ban with the SQL editor, but that doesn't end existing sessions:

SQL
-- Ban a user for a week: every sign-in, refresh and token is refused until then
UPDATE auth_users SET banned_until = now() + interval '7 days' WHERE email = 'user@example.com';

Raw SQL can't DROP, TRUNCATE, ALTER or DELETE FROM an auth_* table, for any caller. Those statements return 403. Remove individual rows from the Auth page or through the row API with the service-role key instead.

Row Level Security with auth.uid()

Each project database has auth.uid(), which returns the signed-in user's id (the token's sub) as text, and auth.role(). Cast when you compare with a uuid column:

SQL
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;

CREATE POLICY "users update own notes" ON notes
  FOR UPDATE USING (owner_id::text = auth.uid());

For a request to run as the user, send the anon key in the apikey header and the user's access token in Authorization:

Terminal
curl -X PUT https://api.orbitnest.io/api/project/my-app/database/tables/notes/rows/42 \
  -H "apikey: $ORBITNEST_ANON_KEY" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Updated"}'
  • The user's claims are applied to row reads through GET .../tables/:table/data and to inserts, updates and deletes.
  • The Flutter SDK sends both headers automatically once a user is signed in. So do @orbitneststudio/js 0.7.0 and @orbitneststudio/node 0.13.0. Earlier JavaScript and Node.js versions send only the API key.

See Database for policies and API keys & JWT for what each key may do.

Rate limits

Per IP address, for each endpoint:

  • Credential endpoints (sign-up, sign-in, code verification, password reset, MFA, user updates) and passkey endpoints: 10 requests per minute.
  • Session endpoints (POST /refresh, GET /verify, POST /signout, POST /signout-all, GET /user, GET /mfa/factors): 120 requests per minute.
  • Social sign-in (/oauth/*): 60 requests per minute.
  • Server-side user management (/auth/admin/*): 120 requests per minute.

Per account, email or phone number, from the project's auth settings (otp_config, rate_limiting, lockout_policy; defaults in brackets). These counters are stored in the project database, so they survive restarts and apply across every API instance:

  • Wrong codes (otp_config.max_attempts, 3): after that many wrong email codes for one address in one flow, its outstanding codes are invalidated. Further attempts return 429 with code OTP_RATE_LIMITED until 15 minutes after the first wrong code, even with a newly requested code. The same rule applies to MFA codes (per user) and SMS codes (per phone number).
  • Code requests (rate_limiting.otp_requests_per_hour, 3): sign-up, email sign-in codes, password recovery and email change together send at most that many codes to one address per hour. Past it they return 429 with code OTP_REQUEST_LIMIT. Password recovery instead answers its usual 200 and sends nothing, so it can't reveal whether the account exists.
  • Wrong passwords (lockout_policy.max_failed_attempts 5, lockout_duration_minutes 30): that many failed password sign-ins for one email within the lockout window lock it for the lockout duration. Sign-in returns 429 with code ACCOUNT_LOCKED, also for the right password, until the lock ends. Unknown emails are counted and locked the same way. A successful sign-in resets the count, and completing a password reset lifts the lock. The lock is separate from an admin ban.
  • Codes are otp_config.code_length digits (6 for new projects; 6 to 8 can be saved, projects created with 4 keep 4 until changed) and expire after otp_config.expiry_minutes (10). They are stored hashed. Test Mode still uses 1234.
  • SMS sends are limited to 1 per minute and 5 per hour per phone number (429 SMS_RATE_LIMITED).
  • Projects without SMTP are limited to 100 auth emails per hour, and 5 per hour per recipient (429 SHARED_EMAIL_RATE_LIMITED). See Email delivery.

Change these settings in Studio → Project Settings → Auth Security, with orbitnest auth-security set (CLI), with the MCP tools get_auth_security / set_auth_security, or with GET / PUT /api/projects/<id>/auth-settings (Studio admin token; PUT merges groups and needs the workspace admin role). Values outside the allowed ranges are refused with 400 INVALID_AUTH_SETTINGS. Every API instance applies a change within 30 seconds.

In your app, branch on the error code: AuthLimitCodes in the Node.js and JavaScript SDKs, and isAccountLocked, isOtpRateLimited, isOtpRequestLimited and isTooManyAttempts on Flutter's AuthException.

Calling auth from your server

The per-IP limits above use the caller's IP. If your server calls auth for its users (for example a Next.js server action), send the user's IP in X-OrbitNest-Client-IP together with the project's service_role key; it then becomes the throttle key and the ip_address in the audit log. The header must hold a single IPv4 or IPv6 address and is ignored with the anon key or a user token. In the Node.js and JavaScript SDKs, pass clientIp to createClient.

REST endpoints

All paths are relative to https://api.orbitnest.io/api/projects/<slug>/auth. "User token" means Authorization: Bearer <access_token>.

Method and pathAuthPurpose
POST /signupNoneEmail and password sign-up, sends a code
POST /signup-with-emailNonePasswordless sign-up, sends a code
POST /verify-signupNoneVerify a sign-up code, create the user, return a session
POST /signinNoneEmail and password sign-in (may return an MFA challenge)
POST /signin-with-emailNoneSend an email sign-in code
POST /verify-signinNoneVerify an email sign-in code
POST /verify-mfaMFA challenge token in the bodyComplete an MFA challenge
POST /sms/send-otp, POST /sms/verify-otpNoneSMS sign-in
POST /recover or /recover-passwordNoneSend a password-reset code
POST /reset-passwordNoneReset a password with a code
POST /refreshNoneExchange a refresh token for a new session
GET /verifyUser tokenCheck an access token
GET /user, PUT /userUser tokenRead or update the current user
POST /user/email/verifyUser tokenConfirm a pending email change with its code
DELETE /userUser tokenDelete the current user's account
POST /signout, POST /signout-allUser tokenEnd this session or all sessions
POST /mfa/totp/enroll, POST /mfa/totp/verifyUser tokenEnroll a TOTP factor
GET /mfa/factors, DELETE /mfa/factors/:idUser tokenList or remove factors
POST /mfa/recovery-codesUser tokenRegenerate recovery codes
POST /passkey/signup/options, /passkey/signup/verifyNonePasskey sign-up
POST /passkey/login/options, /passkey/login/verifyNonePasskey sign-in
POST /passkey/register/options, /passkey/register/verifyUser tokenAdd a passkey
GET /passkey/devices, PATCH or DELETE /passkey/devices/:idUser tokenManage passkeys
GET /oauth/:provider/startNoneStart social sign-in (browser)
POST /oauth/:provider/link/prepareUser tokenStart linking a provider
GET /oauth/identities, DELETE /oauth/identities/:providerUser tokenList or unlink providers
GET, POST /admin/users; GET, PUT, DELETE /admin/users/:id; GET /admin/statsService role keyManage users from your server

Studio accounts

Studio accounts, the people who manage projects, are a separate system. They are stored centrally, not in any project database, and their tokens are signed with a platform-wide secret and carry a different audience from your users' tokens.

  • Sign in to Studio with email and password, with Google, GitHub or Apple, or with a passkey.
  • Add an authenticator app (with recovery codes) and passkeys in Settings → Account. Workspace admins can require two-factor for every member, and the API enforces it. See Team & roles.
  • The CLI and MCP server sign in with your Studio account.

See Team & roles for workspace members and roles.