Sign in with Syntic (OAuth)
Syntic’s own apps (Syntic Code, Syntic Desktop and Syntic Mobile) sign a person in through the browser instead of asking for an API key. The flow is OAuth 2.0 authorization code with PKCE: the app never sees the person’s password, and what it gets back is a short-lived access token plus a refresh token tied to one organization.
Apps are registered by Syntic. Each registered app is a public client: it has an id but no secret, so PKCE and an exactly matching redirect address are what protect the code.
The flow at a glance
- The app makes a random code verifier, hashes it into a code challenge, and makes a random state value.
- The app opens the browser at
https://app.syntic.ai/oauth/authorizewith those values. - The person signs in (if they are not already) and approves. The page sends the browser to the app’s redirect address with
codeandstateadded to the query. - The app checks that
stateis the value it made, then trades the code and the verifier for tokens at the token endpoint. - The app sends the access token as
Authorization: Bearer ...on/v1calls, refreshes it before it expires, and revokes the refresh token when the person signs out.
Registered apps
client_id | App | Redirect address it may use | Scopes it may hold | Scopes when none are asked for |
|---|---|---|---|---|
syntic-code-cli | Syntic Code (CLI) | http://localhost:PORT/callback, http://127.0.0.1:PORT/callback (port 1 to 65535), or https://app.syntic.ai/oauth/code/callback | user:profile, user:inference, user:sessions:syntic_ai, user:mcp_servers, user:file_upload | user:inference |
syntic-desktop | Syntic Desktop | http://127.0.0.1:PORT/callback only (port 1024 to 65535) | user:profile, user:inference, user:billing, user:media, user:settings | all five |
syntic-mobile | Syntic Mobile | ai.syntic.mobile:/oauth/callback, exactly | user:profile, user:inference, settings:write | user:profile |
An unknown client_id is refused before anything else is checked.
Scopes are granted per app. A scope one app may hold is never available to another app just because it exists. No app can be granted a scope that creates API keys: sign-in tokens are the credential, and no long-lived key is minted for them.
Syntic Code needs a paid organization. Signing the CLI in to an organization on the Free plan stops on the approval page with plan_required. Desktop and Mobile may sign in to a Free organization; /v1 then applies its own plan check when a message is sent.
Step 1: make the verifier, challenge and state
| Value | Rule |
|---|---|
code_verifier | 43 to 128 characters from A-Z a-z 0-9 - . _ ~. Use at least 32 random bytes, base64url-encoded without padding (that gives 43 characters). Keep it in memory; it never goes to the browser. |
code_challenge | BASE64URL(SHA-256(code_verifier)), no padding: 43 characters from A-Z a-z 0-9 - _. |
code_challenge_method | S256. It is the only method accepted; plain is refused with unsupported_code_challenge_method. Mobile must send it explicitly; the other apps may leave it out, and it is then taken as S256. |
state | A fresh random value per sign-in, 8 to 512 printable ASCII characters. Required for Mobile. Every app should send it and check it on return. |
import base64, hashlib, secrets
def b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
verifier = b64url(secrets.token_bytes(32))
challenge = b64url(hashlib.sha256(verifier.encode()).digest())
state = b64url(secrets.token_bytes(24))Step 2: open the approval page
https://app.syntic.ai/oauth/authorize
?response_type=code
&client_id=syntic-code-cli
&redirect_uri=http%3A%2F%2F127.0.0.1%3A54545%2Fcallback
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&state=Jb7c1Qy0pXr2uK9mZs4vTg
&scope=user%3Ainference%20user%3Aprofile| Parameter | Required | Notes |
|---|---|---|
response_type | Yes | Must be code. |
client_id | Yes | One of the ids in Registered apps. |
redirect_uri | Yes | Must pass the redirect rules for that app. |
code_challenge | Yes | From step 1. |
code_challenge_method | Mobile: yes. Others: optional | S256. |
state | Mobile: yes. Others: strongly advised | From step 1. Sent back unchanged. |
scope | No | Space- or comma-separated. The page asks for user:inference when it is left out. Any scope the app may not hold fails with invalid_scope. |
organization_id | No | Bind the session to this organization. The person must be an active member. Without it, the session is bound to the person’s own personal workspace. |
If the person is signed out, the page sends them through the normal syntic.ai sign-in (including MFA) and then returns to the approval card. Nothing is sent to the redirect address until they approve. When a check fails (unknown app, bad redirect address, bad challenge, missing state for Mobile, unknown scope, not a member of the organization, Free plan for the CLI), the page shows the error itself and does not redirect.
On approval the browser goes to:
http://127.0.0.1:54545/callback?code=<one-time code>&state=Jb7c1Qy0pXr2uK9mZs4vTgThe code is valid for 5 minutes and can be used once.
Step 3: check the return
Before you use the code:
- Compare
statewith the value you made, character for character. If it is missing or different, throw the code away and show an error. Do not exchange it. - A loopback listener should answer only
GET /callbackon the exact host and port it registered, and should stop listening after the first valid callback. - Use the same
redirect_uristring in step 4 that you sent in step 2.
Redirect address rules
Redirect addresses are compared exactly. There is no prefix matching, no wildcard and no normalising of what you send.
Loopback (CLI and Desktop). Run a small HTTP listener on the loopback interface, on a port the operating system picks, and register it as http://<host>:<port>/callback:
- scheme
http(the listener is local, so TLS is not used;httpsis refused); - host
127.0.0.1(CLI and Desktop) orlocalhost(CLI only); - an explicit port: 1 to 65535 for the CLI, 1024 to 65535 for Desktop. A missing port or port
0is refused; - path exactly
/callback; - no query string, no fragment, no user name or password;
- written in its plain form. The server parses your value, rebuilds
http://<host>:<port>/callback, and refuses the request unless the two strings are identical, so forms such as/./callback, an encoded dot or a trailing slash fail.
Manual paste page (CLI only). When a local listener cannot be used (for example over SSH), the CLI may use https://app.syntic.ai/oauth/code/callback. That page shows the code, and the person pastes it into the terminal. The state check still applies.
Custom scheme (Mobile). ai.syntic.mobile:/oauth/callback, byte for byte. No other form is accepted for Mobile.
The value is checked twice: when the approval page asks for a code (an address outside the rules gets invalid_redirect_uri), and again at the token endpoint, where it must equal the one stored with the code (redirect_uri_mismatch).
Step 4: exchange the code
POST https://app.syntic.ai/v1/oauth/token
Content-Type: application/json{
"grant_type": "authorization_code",
"code": "<one-time code>",
"code_verifier": "<verifier from step 1>",
"client_id": "syntic-code-cli",
"redirect_uri": "http://127.0.0.1:54545/callback"
}The server hashes code_verifier with SHA-256, compares it with the stored challenge, and checks that client_id and redirect_uri match what the code was issued for. A malformed verifier is refused before the code is used up, so a typo does not burn a good code.
Response, 200 OK:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "q3Jx0nS1v8...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "user:inference user:profile",
"account": { "uuid": "4c0f...", "email_address": "dev@example.com" },
"organization": { "uuid": "a91e...", "id": "a91e..." },
"subscription_tier": "business"
}| Field | Notes |
|---|---|
access_token | A signed token, valid for 1 hour. Send it as Authorization: Bearer ... to /v1. Treat it as opaque. |
refresh_token | Valid for 90 days, and replaced on every refresh. Store it in the operating system’s secure store (Keychain, Credential Manager, Secret Service), never in a plain file. |
expires_in | Seconds until the access token expires. |
scope | The granted scopes, space-separated. |
organization | The organization every request made with this token is billed to. |
subscription_tier | For display only. /v1 reads the organization’s plan itself on every request. |
Step 5: refresh
Refresh shortly before expires_in runs out, or when a /v1 call answers 401 invalid_token.
{
"grant_type": "refresh_token",
"refresh_token": "<current refresh token>",
"client_id": "syntic-code-cli"
}You get back the step 4 fields, with a new refresh_token. Save the new one before you use the new access token; the old refresh token stops working at that moment.
Rotation and reuse. All refresh tokens from one sign-in form a session. Presenting a refresh token that has already been replaced is treated as theft: the whole session is revoked and the answer is 401 token_reuse_detected. The person has to sign in again. If two parts of your app might refresh at the same time, put the refresh behind a lock.
A refresh is also refused, and the session revoked, when the person is no longer an active member of the bound organization (org_membership_revoked), and for the CLI when the organization no longer has a paid plan (plan_required). When the server cannot check membership or plan, it answers 503 without using up the refresh token, so the same token works on a retry.
Step 6: sign out (revoke)
On develop, not live yet. The public revoke route below is merged on the platform’s develop branch and is not on production yet.
POST https://app.syntic.ai/oauth/revoke
Content-Type: application/x-www-form-urlencoded/api/oauth/revoke on the same host is an alias. Revocation follows RFC 7009. Send the refresh token; the whole session is ended, and access tokens minted from it stop working at once.
token=<refresh token>&client_id=syntic-code-cli&token_type_hint=refresh_tokenThe body may also be JSON with the same three fields. token and client_id are required; token_type_hint is optional.
- The answer is
200 {"revoked": true}whether or not the token was known, already revoked, or belongs to another app (which is left untouched). This stops the endpoint from being used to test tokens. 401 invalid_client:client_idis missing or not registered.400 invalid_request: no token, one longer than 4,096 characters, or a body that cannot be read.400 unsupported_token_type:token_type_hintisaccess_tokenand the value is not a refresh token. Access tokens are ended by revoking their session.503 temporarily_unavailable: try again.
Responses carry Cache-Control: no-store. Delete the stored tokens locally even if the call fails. Full reference with curl, Python and TypeScript examples: Revoke a sign-in.
Step-up: confirm it’s you
Some actions in an app (for example changing a security setting) need proof that the person signed in recently, not just that the app holds a valid session. For these the app sends the browser to:
https://app.syntic.ai/oauth/step-up?intent=<action>&client_id=...&redirect_uri=...&state=...&code_challenge=...&code_challenge_method=S256&scope=...intentnames the action: 1 to 128 characters fromA-Z a-z 0-9 _ -.- The other parameters are the same as for
/oauth/authorize, with the same rules. organization_idmay be added to keep the session on the same organization. The organization is never switched silently.
The page asks the person to confirm their password (or sign in again with Google, SSO or MFA). The server, not the browser, decides: it issues a code only if the person’s last real sign-in was within 5 minutes (60 seconds of clock skew are tolerated). Otherwise the request is refused with 401 step_up_required, which carries reason (no_auth_time, stale_auth or future_auth_time) and max_age (300).
The app then finishes with the normal state check and code exchange. The first access token from that code carries the step-up (the action and the sign-in time); a protected action accepts it for up to 10 minutes after the sign-in. Refreshed access tokens never carry a step-up, so a step-up cannot be stretched by refreshing.
Error codes
Approval page
These are shown on the page; nothing is redirected to the app.
| Code | Status | When |
|---|---|---|
invalid_client | 400 | client_id is not a registered app. |
invalid_redirect_uri | 400 | redirect_uri breaks the rules for that app. A hint field gives the accepted form. |
invalid_code_challenge | 400 | code_challenge is missing or not 43 to 128 base64url characters. |
unsupported_code_challenge_method | 400 | The method is not S256, or Mobile left it out. |
invalid_request | 400 | Mobile sent no valid state, or a step-up sent a malformed intent. |
invalid_scope | 400 | A scope the app may not hold was asked for. |
org_context_required | 400 | No organization_id was given and the person has no personal workspace. |
step_up_required | 401 | A step-up was asked for and the last sign-in is older than 5 minutes. |
not_member_of_org | 403 | The person is not an active member of organization_id. |
plan_required | 403 | CLI only: the organization is on the Free plan. |
Token endpoint
| Code | Status | When | Retry? |
|---|---|---|---|
invalid_json | 400 | The body is not JSON. | No. |
unsupported_grant_type | 400 | grant_type is not authorization_code or refresh_token. | No. |
invalid_request | 400 | A required field is missing, or code_verifier breaks the length or character rule. | No. |
invalid_grant | 400 | The code or refresh token is unknown or already used. | No. Sign in again. |
code_expired | 400 | The code is older than 5 minutes. | No. Start a new sign-in. |
client_id_mismatch | 400 | The code or refresh token was issued to another app. | No. |
redirect_uri_mismatch | 400 | redirect_uri differs from the one used on the approval page. | No. |
pkce_verification_failed | 400 | SHA-256 of the verifier does not match the challenge. | No. Start a new sign-in. |
invalid_scope | 400 | The scopes on the code or session are no longer allowed for this app. | No. Sign in again. |
account_disabled | 401 | The account is disabled. | No. |
refresh_token_expired | 401 | The refresh token is past its 90 days. | No. Sign in again. |
token_revoked | 401 | The session was revoked (sign-out, admin action). | No. Sign in again. |
token_reuse_detected | 401 | An old refresh token was presented; the session is now revoked. | No. Sign in again. |
org_membership_revoked | 401 | The person was removed from the bound organization; the session is revoked. | No. |
not_member_of_org | 403 | Code exchange: the person has left the organization or was removed from it. | No. |
plan_required | 403 | CLI refresh: the organization has no paid plan; the session is revoked. | No. Upgrade, then sign in again. |
temporarily_unavailable | 503 | A check could not run. Nothing was used up. | Yes, with the same token. |
refresh_failed, consume_failed | 500 | The server could not complete the step. | Yes, with back-off. |
server_misconfigured | 500 | The server could not sign a token. On refresh, nothing was used up. | Yes, later. |
Checklist for app builders
- New verifier, challenge and state for every sign-in; never reused.
-
code_challenge_method=S256sent every time. -
statecompared on return; a mismatch ends the sign-in. - One
redirect_uristring, built once, used for the approval page, the listener and the token request. - Listener bound to the loopback address only, closed after the first valid callback.
- Refresh tokens kept in the system’s secure store and replaced after every refresh.
- One refresh at a time.
- Sign-out calls revoke, then deletes local tokens even if revoke fails.