Authentication
Sessions with access and refresh tokens, personal API keys, and how to send and renew them.
Every SAT API call except sign-in, registration, token refresh, password reset and /health needs a bearer credential. There are two kinds. A session starts with an email and password and gives you a short-lived access token plus a refresh token; the web app and an interactive sat use sessions. A personal API key is a long-lived credential for scripts, CI and runners. Both authenticate as a person, and company endpoints then check that this person is a member of the company.
Send the credential
Authorization: Bearer <access token or sat_live_ API key>The API tells the two apart by the sat_live_ (or sat_test_) prefix. Everything else is treated as an access token. The live event stream uses the same header; see Live events.
| Problem | Answer |
|---|---|
No Authorization header, or not Bearer | 401 AUTH_REQUIRED, message Not authenticated |
| Expired, malformed or revoked credential | 401 INVALID_TOKEN, message Could not validate credentials |
| Valid credential, but not a member of the company | 404 NOT_FOUND (Company not found) |
Sessions
Register
curl -sS -X POST "$SAT_API_URL/api/register" -H "Content-Type: application/json" \
-d '{"email": "ada@noteflow.example", "password": "a-long-passphrase", "name": "Ada Lovelace"}'email must be a valid address, password 8 to 128 characters (the same rule as a password reset and the web app's forms), name 1 to 255 characters; company is optional. The answer is 201 with user, access_token and refresh_token, so the new user is signed in. An email already registered answers 409 USER_EXISTS. There is no email verification.
Sign in
curl -sS -X POST "$SAT_API_URL/api/login" -H "Content-Type: application/json" \
-d '{"email": "ada@noteflow.example", "password": "a-long-passphrase"}'{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {"id": "0f8e...", "email": "ada@noteflow.example", "name": "Ada Lovelace", "created_at": "2026-09-01T08:00:00+00:00", "avatar_url": null, "preferences": {}}
}| Error | Status | When |
|---|---|---|
INVALID_CREDENTIALS | 401 | Unknown email or wrong password |
ACCOUNT_LOCKED | 400 | The account is locked after failed attempts (see below) |
ACCOUNT_INACTIVE | 400 | The account is deactivated |
Sign in with Google
GET /api/auth/config returns {"google_client_id": "<id>" | null}; no credential needed. When it is not null, get a Google ID token with Google Identity Services for that client ID and exchange it:
curl -s -X POST "$SAT_API_URL/api/login/google" \
-H 'Content-Type: application/json' \
-d '{"credential": "<Google ID token>"}'The answer has the same shape as POST /api/login (access_token, refresh_token, user). The token must be signed by Google, issued for the configured client ID, unexpired, and carry a verified email; the account with that email is signed in, or created. Errors: 401 GOOGLE_SIGN_IN_FAILED (token rejected), 404 GOOGLE_DISABLED (no client ID configured), 429 RATE_LIMITED (10 a minute per IP). See Sign in with Google.
Refresh
curl -sS -X POST "$SAT_API_URL/api/refresh" -H "Content-Type: application/json" \
-d "{\"refresh_token\": \"$REFRESH_TOKEN\"}"The answer is {"access_token": "..."}. The refresh token is not rotated: keep using the same one until it expires, 7 days after sign-in. Then the user must sign in again. A refresh token that is expired, revoked by sign-out or a password reset, or malformed answers 401 INVALID_REFRESH_TOKEN.
Sign out
POST /api/logout needs a session access token in the Authorization header. An API key is refused with 403 SESSION_REQUIRED, so an unattended key holder such as an agent cannot end its owner's sessions.
| Body | Effect |
|---|---|
{"refresh_token": "..."} | Ends only that session: one browser or one CLI profile. This is what the web app and sat logout send |
| No body | Ends every session of the user, browsers included. This is sat logout --everywhere |
Signing out revokes refresh tokens. Access tokens are not revocable: one already issued stays valid until it expires, up to 30 minutes later.
The current user
| Call | Purpose |
|---|---|
GET /api/me | id, email, name, created_at, avatar_url, preferences |
PATCH /api/me | Change name or avatar_url (an https:// URL; null clears it) |
PATCH /api/me/preferences | Merge a partial preferences update: objects merge key by key, lists replace, null removes a setting |
Tokens
Access and refresh tokens are JWTs signed with HS256 using the API's JWT_SECRET_KEY. Without that variable the API falls back to a built-in development secret only when APP_ENV (or, if unset, ENVIRONMENT) is development, dev, local or test, ignoring case; with any other value it refuses to start. See Configuration.
| Access token | Refresh token | |
|---|---|---|
| Lifetime | 30 minutes | 7 days |
| Claims | sub (user id), type: "access", iat, exp | sub, type: "refresh", iat, exp, jti (unique id) |
| Stored by the server | No | Yes, as a SHA-256 hash, so it can be revoked |
| Accepted as a bearer credential | Yes | No |
Treat tokens as opaque. Read the user from GET /api/me rather than from the token.
Account lockout
Five wrong passwords in a row lock the account for 30 minutes. The fifth attempt still answers 401 INVALID_CREDENTIALS; from then on, every sign-in answers 400 ACCOUNT_LOCKED, even with the correct password, until the lock expires.
- A successful sign-in resets the failure count.
- When a lock has expired, the next sign-in attempt starts the count again from zero, so it takes another five wrong passwords to lock the account again.
- A password reset clears the lock and the count.
Sign-in has no per-IP rate limit; lockout is the only throttle.
Password reset
Request a link
POST /api/password/forgot with {"email": "..."} always answers 202 with the same message, whether or not the email has an account. If it does, the user gets an email with a link that works once and expires after 30 minutes. Limited to 5 requests per minute per IP address.
Set the new password
POST /api/password/reset with {"token": "...", "password": "..."}. The new password must be 8 to 128 characters. An invalid, used or expired token answers 400 INVALID_RESET_TOKEN. Limited to 10 requests per minute per IP address.
A reset ends every credential issued before it: it revokes all refresh tokens, access tokens issued before the change stop working, and API keys created before the change answer 401 INVALID_TOKEN from then on. Create new keys after the reset for your runners and scripts. The reset also revokes those keys, so they leave the key list and stop counting toward the 20-key limit and the name check.
The supported API has no change-password endpoint for a signed-in user yet. Use the reset flow.
API keys
A personal API key authenticates exactly like an access token, as the member who created it, but it does not expire unless you ask. Use one for anything unattended: runners, CI jobs, scripts.
| Property | Value |
|---|---|
| Format | sat_live_ followed by 32 URL-safe characters, 41 characters in all |
| Stored | Only the SHA-256 hash. The key is shown once, in the create response |
prefix | The first 13 characters, for example sat_live_Krwo, to recognize a key in lists |
| Expiry | None by default. expires_in_days from 1 to 3650 sets one |
last_used | Updated at most once a minute |
| Limit | 20 active keys per user; names unique among active keys. Active means not revoked and not expired |
| Scope | Everything the member can do, in every company they belong to |
sat apikey create ci-runner --expires-in-days 90 # printed once
sat apikey create grace-laptop-runner --store # also store it for this profile
sat apikey list
sat apikey revoke ci-runnerThe create response is 201:
{
"id": "5e1c...",
"name": "ci-runner",
"prefix": "sat_live_Krwo",
"created_at": "2026-10-05T10:00:00+00:00",
"last_used": null,
"expires_at": "2027-01-03T10:00:00+00:00",
"key": "sat_live_Krwo..."
}List returns the same fields without key, newest first, active keys only: revoked and expired keys are left out.
| Error | Status | When |
|---|---|---|
SESSION_REQUIRED | 403 | Creating or revoking with an API key. Sign in with a password to manage keys. The legacy /api/users/{username}/api-keys routes refuse API keys the same way |
API_KEY_EXISTS | 409 | An active key already has this name. A revoked or expired key's name can be reused |
API_KEY_LIMIT | 400 | The user already has 20 active keys. Revoked and expired keys do not count |
NOT_FOUND | 404 | Revoking a key that does not exist, is already revoked or expired, or belongs to someone else |
Revocation takes effect on the next request. A key also stops working when it expires, when the user is deactivated, or when the user completes a password reset after the key was created.
Refresh on 401
Access tokens last 30 minutes, so a long-lived client will see 401s. Handle them the way @sat/api-client does:
- Send the request with the current access token.
- On 401, refresh. Share one refresh among all requests that failed at the same time, so a burst of 401s causes one
POST /api/refresh. - If the refresh succeeded, store the new access token and retry the request once.
- If the refresh failed, or the retry is still 401, drop the tokens and send the user to sign in.
let refreshing: Promise<boolean> | null = null;
async function refresh(): Promise<boolean> {
const refreshToken = store.getRefreshToken();
if (!refreshToken) return false;
refreshing ??= fetch(`${baseUrl}/api/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
})
.then(async (res) => {
if (!res.ok) return false;
const body = await res.json();
store.setTokens(body.access_token, body.refresh_token); // refresh_token is not returned today
return true;
})
.catch(() => false)
.finally(() => {
refreshing = null;
});
return refreshing;
}
export async function authedFetch(path: string, init: RequestInit = {}): Promise<Response> {
const send = () => {
const headers = new Headers(init.headers);
const token = store.getAccessToken();
if (token) headers.set('Authorization', `Bearer ${token}`);
return fetch(`${baseUrl}${path}`, { ...init, headers });
};
let res = await send();
if (res.status === 401 && (await refresh())) res = await send();
if (res.status === 401) {
store.clear();
onUnauthorized(); // for example, redirect to /login
}
return res;
}In TypeScript, createApiClient from @sat/api-client already does this; see Typed client. With an API key there is nothing to refresh: a 401 means the key was revoked, expired, or created before the owner's last password reset.
Security notes
- Keep refresh tokens and API keys secret. Either one gives full access as the user until it is revoked or expires.
- Sign-out does not cut off an access token already issued; it expires within 30 minutes.
- A password reset ends API keys too. Keys created before the reset stop working; create new ones afterwards.
- API keys are not scoped. A key can do anything its owner can, in every company. Give each machine its own key and use
expires_in_days. - Some actions need a session. An API key gets 403
SESSION_REQUIREDwhen it tries to create or revoke keys (through/api/me/api-keysor the legacy/api/users/{username}/api-keysroutes) or to sign out (POST /api/logout). Everything else accepts a key, includingPATCH /api/meandPATCH /api/me/preferences. See Runner security.