API referenceOAuthSign in with Syntic

Sign in with Syntic (OAuth)

⚠️
Preview — this feature is being built; the design below is the spec. Not live yet. The values below are taken from the platform code that is being rolled out; the public paths and the list of registered apps can still change before launch.

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

  1. The app makes a random code verifier, hashes it into a code challenge, and makes a random state value.
  2. The app opens the browser at https://app.syntic.ai/oauth/authorize with those values.
  3. The person signs in (if they are not already) and approves. The page sends the browser to the app’s redirect address with code and state added to the query.
  4. The app checks that state is the value it made, then trades the code and the verifier for tokens at the token endpoint.
  5. The app sends the access token as Authorization: Bearer ... on /v1 calls, refreshes it before it expires, and revokes the refresh token when the person signs out.

Registered apps

client_idAppRedirect address it may useScopes it may holdScopes when none are asked for
syntic-code-cliSyntic Code (CLI)http://localhost:PORT/callback, http://127.0.0.1:PORT/callback (port 1 to 65535), or https://app.syntic.ai/oauth/code/callbackuser:profile, user:inference, user:sessions:syntic_ai, user:mcp_servers, user:file_uploaduser:inference
syntic-desktopSyntic Desktophttp://127.0.0.1:PORT/callback only (port 1024 to 65535)user:profile, user:inference, user:billing, user:media, user:settingsall five
syntic-mobileSyntic Mobileai.syntic.mobile:/oauth/callback, exactlyuser:profile, user:inference, settings:writeuser: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

ValueRule
code_verifier43 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_challengeBASE64URL(SHA-256(code_verifier)), no padding: 43 characters from A-Z a-z 0-9 - _.
code_challenge_methodS256. 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.
stateA 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
ParameterRequiredNotes
response_typeYesMust be code.
client_idYesOne of the ids in Registered apps.
redirect_uriYesMust pass the redirect rules for that app.
code_challengeYesFrom step 1.
code_challenge_methodMobile: yes. Others: optionalS256.
stateMobile: yes. Others: strongly advisedFrom step 1. Sent back unchanged.
scopeNoSpace- 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_idNoBind 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=Jb7c1Qy0pXr2uK9mZs4vTg

The code is valid for 5 minutes and can be used once.

Step 3: check the return

Before you use the code:

  • Compare state with 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 /callback on the exact host and port it registered, and should stop listening after the first valid callback.
  • Use the same redirect_uri string 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; https is refused);
  • host 127.0.0.1 (CLI and Desktop) or localhost (CLI only);
  • an explicit port: 1 to 65535 for the CLI, 1024 to 65535 for Desktop. A missing port or port 0 is 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"
}
FieldNotes
access_tokenA signed token, valid for 1 hour. Send it as Authorization: Bearer ... to /v1. Treat it as opaque.
refresh_tokenValid 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_inSeconds until the access token expires.
scopeThe granted scopes, space-separated.
organizationThe organization every request made with this token is billed to.
subscription_tierFor 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_token

The 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_id is 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_hint is access_token and 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=...
  • intent names the action: 1 to 128 characters from A-Z a-z 0-9 _ -.
  • The other parameters are the same as for /oauth/authorize, with the same rules.
  • organization_id may 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.

CodeStatusWhen
invalid_client400client_id is not a registered app.
invalid_redirect_uri400redirect_uri breaks the rules for that app. A hint field gives the accepted form.
invalid_code_challenge400code_challenge is missing or not 43 to 128 base64url characters.
unsupported_code_challenge_method400The method is not S256, or Mobile left it out.
invalid_request400Mobile sent no valid state, or a step-up sent a malformed intent.
invalid_scope400A scope the app may not hold was asked for.
org_context_required400No organization_id was given and the person has no personal workspace.
step_up_required401A step-up was asked for and the last sign-in is older than 5 minutes.
not_member_of_org403The person is not an active member of organization_id.
plan_required403CLI only: the organization is on the Free plan.

Token endpoint

CodeStatusWhenRetry?
invalid_json400The body is not JSON.No.
unsupported_grant_type400grant_type is not authorization_code or refresh_token.No.
invalid_request400A required field is missing, or code_verifier breaks the length or character rule.No.
invalid_grant400The code or refresh token is unknown or already used.No. Sign in again.
code_expired400The code is older than 5 minutes.No. Start a new sign-in.
client_id_mismatch400The code or refresh token was issued to another app.No.
redirect_uri_mismatch400redirect_uri differs from the one used on the approval page.No.
pkce_verification_failed400SHA-256 of the verifier does not match the challenge.No. Start a new sign-in.
invalid_scope400The scopes on the code or session are no longer allowed for this app.No. Sign in again.
account_disabled401The account is disabled.No.
refresh_token_expired401The refresh token is past its 90 days.No. Sign in again.
token_revoked401The session was revoked (sign-out, admin action).No. Sign in again.
token_reuse_detected401An old refresh token was presented; the session is now revoked.No. Sign in again.
org_membership_revoked401The person was removed from the bound organization; the session is revoked.No.
not_member_of_org403Code exchange: the person has left the organization or was removed from it.No.
plan_required403CLI refresh: the organization has no paid plan; the session is revoked.No. Upgrade, then sign in again.
temporarily_unavailable503A check could not run. Nothing was used up.Yes, with the same token.
refresh_failed, consume_failed500The server could not complete the step.Yes, with back-off.
server_misconfigured500The 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=S256 sent every time.
  • state compared on return; a mismatch ends the sign-in.
  • One redirect_uri string, 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.
Was this page helpful?