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_logand 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 inAuthorization: Bearer. - A successful sign-in returns a session object:
access_token,refresh_token,expires_inanduser. - Tokens are HS256 JWTs signed with the project's JWT secret. Lifetimes are configurable. See API keys & JWT.
- Banned users (
auth_users.banned_untilin the future) are refused everywhere: sign-in by any method, refresh, social and passkey sign-in, and every request that carries their access token.
| Method | JavaScript / Node.js SDK | Flutter SDK | REST |
|---|---|---|---|
| Email and password | Yes | Yes | Yes |
| Email one-time codes (passwordless) | No | Yes | Yes |
| SMS one-time codes | Yes | Yes | Yes |
| Two-factor (TOTP) | Yes | Yes | Yes |
| Passkeys | Yes (you run the browser ceremony) | Yes | Yes |
| Social sign-in (Google, GitHub, Apple) | No | No | Browser redirect |
The examples below use these clients:
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)
});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 ano-replyReply-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
429with codeSHARED_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.
signUpstores the hashed password and emails a code (6 digits by default, set byotp_config.code_length). The code is valid for 10 minutes by default.verifySignUpchecks the code, creates the user, and returns a session.
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',
});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
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);
}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
- 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.
- Submit the code with the new password (8 to 128 characters). This ends every session, and existing access tokens stop working at once.
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',
});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.
// 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');# 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-emailaccepts optionaluser_metadata.verify-signupaccepts an optionalpassword(8–128 characters) if you want the account to have one.- For an unknown email,
signin-with-emailreturns the same success response as a real send and emails nothing, so the endpoint can't be used to discover accounts. A banned account returns401.
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
await orbit.auth.signInWithSms('+15555550123');
const { data: session, error } = await orbit.auth.verifySmsOtp('+15555550123', '482913');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
400if 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-otpreturns429with codeSMS_RATE_LIMITED. - After
otp_config.max_attemptswrong codes for a number (3 by default),verify-otpreturns429with codeOTP_RATE_LIMITEDfor 15 minutes from the first failure. - A new phone user gets a placeholder email of the form
15555550123@sms.orbitnestand 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).
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 oncefinal 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_tokenonly works atverify-mfa. It is not an access token and is refused everywhere else. - After
otp_config.max_attemptswrong codes (3 by default),verify-mfareturns429with codeOTP_RATE_LIMITEDfor 15 minutes from the first failure. - A banned user gets
401even 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, withcodein 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.
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');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. Eachsha256_cert_fingerprintsentry of anandroid_appstatement with thedelegate_permission/common.get_login_creds(orhandle_all_urls) relation becomes anandroid:apk-key-hash:origin. - Any Extra origins you add under the advanced field, one per line and up to 20:
https://host[:port]orandroid:apk-key-hash:<base64url>. Use it for keys yourassetlinks.jsoncan't list, such as a local debug signing key. Wildcards aren't accepted.
Host these two files at the app domain:
| File | Requirements |
|---|---|
/.well-known/apple-app-site-association | HTTP 200, Content-Type: application/json, no redirect, no .json extension. Contains "webcredentials": { "apps": ["<TEAMID>.<bundleId>"] }. |
/.well-known/assetlinks.json | HTTP 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:
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 withregisterPasskeyOptions. - The one exception is an abandoned passkey sign-up for that email (never completed, never signed in, no password), which can be retried.
signUpWithPasskeyVerifyreturns409if 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):
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
- Open Project → Settings → Social Login and choose a provider.
- Copy the Callback URL,
https://api.orbitnest.io/api/projects/<slug>/auth/oauth/<provider>/callback, into the provider's OAuth app settings. - Enter the Client ID and Client secret. For Apple, also enter your Team ID, Key ID, and the
.p8private key. - 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 anhttporhttpsURL. - 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:
https://api.orbitnest.io/api/projects/my-app/auth/oauth/google/startAdd ?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:
https://myapp.com/auth/callback#access_token=...&refresh_token=...&expires_in=900&token_type=Bearer&action=signin&provider=googleconst 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
- If the provider account is already linked to a user, that user is signed in.
- 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.
- Otherwise a new user is created without a password. Their
user_metadatagetsfull_name,avatar_urlandprovider.
Link and unlink providers
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:
{
"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
{
"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/js0.7.0,@orbitneststudio/node0.13.0): the SDK refreshes the session automatically within 30 seconds of expiry, before it sends a request. You can also callorbit.auth.refreshSession()yourself. Read the session withorbit.auth.getSession(), the user withorbit.auth.getUser(), and listen for changes withorbit.auth.onAuthStateChange(cb). - Flutter: the SDK refreshes on a
401and retries the request. Listen toauth.onAuthStateChangefor state changes.
Signing out
signOut()deletes the current session. Its refresh token and its access token stop working at once.POST /auth/signout-allwith 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
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',
});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 returns401with codeREAUTH_REQUIRED. - Email changes are confirmed by code. A new
emaildoesn't take effect right away. The API emails a one-time code to the new address and returns the user with the oldemailplusnew_email. Confirm withPOST /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:).
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
// 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
429with codeEMAIL_CHANGE_RATE_LIMITED. A new request replaces the pending address. - The code expires after 10 minutes by default.
otp_config.max_attemptswrong codes invalidate it and return429with codeOTP_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
emailclaim until it is refreshed. verifyEmailChangeis 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:
await orbit.auth.deleteUser(); // clears the local session on successawait 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, useauth.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.
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 path | Body or query | Result |
|---|---|---|
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/users | email, optional password (8 to 128 characters), email_confirm, user_metadata, app_metadata | { user }, or 409 if the email is taken |
PUT /auth/admin/users/:id | Any 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_untilandnew_email. Deleted users are not listed. PUTmergesuser_metadataandapp_metadatakey by key. Anemailset here applies at once, without a code, and is unconfirmed unlessemail_confirmistrue.- A
banned_untilin the future, or a newpassword, signs the user out of every session. Banned users can't sign in, refresh or use their tokens until that time.banned_until: nulllifts the ban. DELETEdoes the same scrub asdeleteUser().- Without a
password,createUsermakes 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.adminis 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:
-- 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:
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:
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/dataand to inserts, updates and deletes. - The Flutter SDK sends both headers automatically once a user is signed in. So do
@orbitneststudio/js0.7.0 and@orbitneststudio/node0.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 return429with codeOTP_RATE_LIMITEDuntil 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 return429with codeOTP_REQUEST_LIMIT. Password recovery instead answers its usual200and sends nothing, so it can't reveal whether the account exists. - Wrong passwords (
lockout_policy.max_failed_attempts5,lockout_duration_minutes30): that many failed password sign-ins for one email within the lockout window lock it for the lockout duration. Sign-in returns429with codeACCOUNT_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_lengthdigits (6 for new projects; 6 to 8 can be saved, projects created with 4 keep 4 until changed) and expire afterotp_config.expiry_minutes(10). They are stored hashed. Test Mode still uses1234. - 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 path | Auth | Purpose |
|---|---|---|
POST /signup | None | Email and password sign-up, sends a code |
POST /signup-with-email | None | Passwordless sign-up, sends a code |
POST /verify-signup | None | Verify a sign-up code, create the user, return a session |
POST /signin | None | Email and password sign-in (may return an MFA challenge) |
POST /signin-with-email | None | Send an email sign-in code |
POST /verify-signin | None | Verify an email sign-in code |
POST /verify-mfa | MFA challenge token in the body | Complete an MFA challenge |
POST /sms/send-otp, POST /sms/verify-otp | None | SMS sign-in |
POST /recover or /recover-password | None | Send a password-reset code |
POST /reset-password | None | Reset a password with a code |
POST /refresh | None | Exchange a refresh token for a new session |
GET /verify | User token | Check an access token |
GET /user, PUT /user | User token | Read or update the current user |
POST /user/email/verify | User token | Confirm a pending email change with its code |
DELETE /user | User token | Delete the current user's account |
POST /signout, POST /signout-all | User token | End this session or all sessions |
POST /mfa/totp/enroll, POST /mfa/totp/verify | User token | Enroll a TOTP factor |
GET /mfa/factors, DELETE /mfa/factors/:id | User token | List or remove factors |
POST /mfa/recovery-codes | User token | Regenerate recovery codes |
POST /passkey/signup/options, /passkey/signup/verify | None | Passkey sign-up |
POST /passkey/login/options, /passkey/login/verify | None | Passkey sign-in |
POST /passkey/register/options, /passkey/register/verify | User token | Add a passkey |
GET /passkey/devices, PATCH or DELETE /passkey/devices/:id | User token | Manage passkeys |
GET /oauth/:provider/start | None | Start social sign-in (browser) |
POST /oauth/:provider/link/prepare | User token | Start linking a provider |
GET /oauth/identities, DELETE /oauth/identities/:provider | User token | List or unlink providers |
GET, POST /admin/users; GET, PUT, DELETE /admin/users/:id; GET /admin/stats | Service role key | Manage 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.