Reference
Authentication
This page is about your app's end users — the people signing into the product you build on Aaly. Connecting an AI coding agent to the platform itself is a separate flow; see Connect an AI agent.
Sign up
POST /<your-slug>/<project>/auth/signup
{ "name": "Ada", "password": "...", "email": "ada@example.com" }
name and password are required; at least one of email, phone, or
username identifies the account. Returns 201 with:
{ "jwt": "...", "refreshToken": "...", "expiresIn": 3600,
"user": { "id": "USR1", "name": "Ada", "tenantId": "TNT1", "email": "ada@example.com", "emailVerified": false } }
Tenant behavior on signup depends on the project's publicSignup and
multiTenant flags — see Multi-tenancy for the full
model. In short: signing up without a tenantId only creates a new tenant
at all when the project's publicSignup is true; signing up with an
existing tenantId joins that tenant and shares its data with every other
user in it. tenantName only names a tenant you're creating — it's ignored
when joining an existing one via tenantId.
Sign in
POST /<your-slug>/<project>/auth/signin
{ "identifier": "ada@example.com", "password": "..." }
identifier is an email, phone, or username. Returns the same
{ jwt, user } shape as signup — unless the identifier belongs to more than
one tenant and no tenantId was passed, in which case you get a
tenant-selection response instead:
{ "requiresTenantSelection": true, "tenants": [{ "tenantId": "TNT1", "tenantName": "Acme" }] }
Re-send the request with the chosen tenantId to complete sign-in.
Google sign-in
POST /<your-slug>/<project>/auth/google
{ "idToken": "<Google OIDC ID token from your client-side sign-in>" }
Verifies the token against Google, then signs in an existing user matched by email, or registers a new one (auto-creating a tenant). Same tenant-selection response as sign-in if the email matches more than one tenant.
Tokens
- JWT — returned by signup/signin/Google,
HS256-signed, valid for one hour (expiresIn: 3600). Send asAuthorization: Bearer <jwt>. - Refresh token — returned alongside the JWT. It rotates: every call to
POST /auth/refreshreturns a fresh JWT and a new refresh token, and the one you sent stops working. Store both, overwrite on every refresh, and never fire two refreshes at once. Replaying an already-used refresh token revokes the whole session.POST /auth/logoutrevokes one session. - API key —
aaly_<id>_<secret>, for CI, scripts, and server-to-server calls that shouldn't hold a short-lived user token:
POST /<your-slug>/<project>/auth/keys
Authorization: Bearer <jwt>
Returns the full token once:
{ "id": "KEY1", "token": "aaly_KEY1_xxxxxxxxxxxxxxxx", "createdAt": "2026-08-24T10:00:00.000Z" }
GET /auth/keys lists key metadata (never the secret again);
DELETE /auth/keys/{id} revokes one. A JWT and an API key are accepted
identically on every endpoint — nothing is JWT-only.
Email verification
Each project chooses how much verification it wants with the emailVerification
project setting (set it with the update_project MCP tool):
| Mode | Verification email on signup | Sign-in before verifying |
|---|---|---|
off (default) | not sent | allowed |
optional | sent | allowed, emailVerified is tracked |
required | sent | refused with 403 EMAIL_NOT_VERIFIED |
The email carries both a link and a 6-digit code, valid for 24 hours. The link opens a confirmation page served by the API itself, so a web app builds nothing. Use the code where a link can't open your app:
POST /<your-slug>/<project>/auth/verify
{ "identifier": "ada@example.com", "code": "482913" }
POST /auth/verify-send resends it (once a minute, five times an hour). With
required, signup returns { user, verificationRequired: true } and no
session: show a "check your email" screen. Users who signed in with Google
are already verified, and a phone-only or username-only user is never gated.
Switching a live project to required locks out every existing user who has
never verified, so raise it before flipping the setting.
Forgot and reset password
POST /<your-slug>/<project>/auth/forgot-password
{ "identifier": "ada@example.com" }
POST /<your-slug>/<project>/auth/reset-password
{ "identifier": "ada@example.com", "code": "482913", "newPassword": "..." }
The code is 6 digits, valid for 15 minutes, and works once; five wrong guesses
burn it. forgot-password answers identically whether or not the account
exists. A successful reset signs out every existing session and emails the
owner a "your password was changed" notice. There is no hosted reset page: your
app collects the code.
To change a password while signed in, POST /auth/change-password with
currentPassword and newPassword. It signs out the user's other sessions and
returns a fresh session to replace the stored tokens.
These emails send from no-reply@aaly.io by default, branded with your
project's name, plus a logo and colour if you set logoUrl and brandColor.
To send from your own domain, configure an SMTP channel: see
Notifications.
What's not here yet
No MFA, no on-demand "sign out everywhere" (a password reset or change does
sign out other sessions; otherwise they revoke one at a time), no change-email
flow (email, phone and username are immutable once set), and no RBAC.
See Limits.