Authentication & OAuth

Two authentication methods are supported on InterlinedList's HTTP API.

MethodWhere to use it
Session cookieThe web app and any browser client on the same origin. Set by POST /api/auth/login.
Bearer tokenNative, mobile, desktop, and CLI clients. Obtained from POST /api/auth/sync-token and sent as Authorization: Bearer <token>.

Endpoints in the reference below labelled Session or Bearer accept either. Endpoints labelled Session only require the cookie.

Login

POST /api/auth/login
Content-Type: application/json

{ "email": "you@example.com", "password": "yourpassword" }

Response (200):

{
  "message": "Login successful",
  "user": {
    "id": "clx7k2m0p0000abc123def456",
    "email": "you@example.com",
    "username": "yourhandle",
    "displayName": "Your Name",
    "avatar": null,
    "bio": null,
    "theme": "darkone",
    "emailVerified": true,
    "createdAt": "2025-03-12T18:00:00.000Z"
  }
}

The user object is nested under user. It does not include customerStatus; retrieve the current subscription tier from the user/profile endpoints (see Users and Profile).

The server sets an HTTP-only session cookie. Its name is configurable via the SESSION_COOKIE_NAME env var; standard deployments use interlinedlist_session. The cookie value is a comma-separated list of Session IDs (never a user ID), supporting up to 5 signed-in accounts with the first as the active one. Include credentials: 'include' in any fetch call (or withCredentials: true in Axios) to carry it on subsequent requests.

Errors: 401 { "error": "Invalid email or password" }. Login does not gate on email verification.

Obtaining a Bearer token

POST /api/auth/sync-token
Content-Type: application/json

{ "email": "you@example.com", "password": "yourpassword" }

Response (200):

{ "token": "3f1c9e...<64 hex chars>...a8", "message": "Sync token created" }

The token is an opaque 64-character lowercase hex string with no prefix. Store it securely (e.g. .env, OS keychain). It does not expire automatically and is stored hashed on the server. Send it as Authorization: Bearer <token>.

GET /api/messages
Authorization: Bearer 3f1c9e...<64 hex chars>...a8

Registering a new account

POST /api/auth/register
Content-Type: application/json

{
  "email": "new@example.com",
  "password": "strongpassword123",
  "username": "myhandle"
}

Response (201): { "message": "User created successfully", "user": { ... } } (the new user, emailVerified: false). A verification email is sent automatically.

To resend the verification email later:

POST /api/auth/send-verification-email

To complete verification using the token from the email link:

POST /api/auth/verify-email
Content-Type: application/json

{ "token": "<token-from-email-link>" }

Password reset

POST /api/auth/forgot-password
Content-Type: application/json

{ "email": "you@example.com" }

Response (200): Always { "message": "If that email exists, a reset link has been sent." } (to prevent email enumeration).

Submit the new password with the token from the email link:

POST /api/auth/reset-password
Content-Type: application/json

{ "token": "<token-from-email-link>", "password": "newstrongpassword" }

OAuth providers

All OAuth flows are redirect-based. Navigate to the authorize URL; on success the server redirects back to the app with a session cookie set.

ProviderSign in / link URLNotes
GitHubGET /api/auth/github/authorizePKCE
MastodonGET /api/auth/mastodon/authorize?instance=mastodon.socialinstance query param required
BlueskyGET /api/auth/bluesky/authorizeOptional handle query param
LinkedInGET /api/auth/linkedin/authorize
X (Twitter)GET /api/auth/twitter/authorizePKCE

Append ?link=true to any of the above to add the identity to an already-authenticated session rather than starting a new sign-in.

Native clients can append ?redirect_uri=<your-app-scheme>://callback so the callback returns a Bearer token instead of setting a cookie. This is supported by every OAuth provider except GitHub (whose authorize handler ignores redirect_uri). See the next section for the canonical iOS flow.

Check whether a provider is configured on the current deployment:

  • GET /api/auth/linkedin/status{ "configured": true, "redirectUri": "..." }
  • GET /api/auth/twitter/status{ "configured": true, "redirectUri": "..." }

Mobile / Native OAuth (Bearer-token handoff)

This section is the canonical reference for the iOS / native OAuth flow. The same pattern works for any custom-scheme client (macOS, Android, CLI, desktop).

Canonical iOS redirect URI

interlinedlist://oauth/callback

Phase 2 prerequisite: backend support is already in place

Server-side support for the iOS custom scheme is already shipped. There are no backend code changes required to begin Phase 2 client work.

LayerStatusDetail
Custom-scheme detectionDoneAny non-http(s) redirect_uri is recognized as a mobile flow; the callback issues a Bearer token instead of setting a session cookie.
Redirect-URI allowlistDoneThe OAUTH_ALLOWED_REDIRECT_URIS env var is the source of truth. Any URI not on the list is rejected before the user reaches the provider.
Provider coveragePartialMastodon, Bluesky, LinkedIn, and X (Twitter) take the mobile branch automatically when redirect_uri is custom-scheme. GitHub does not: its authorize handler ignores redirect_uri, so GitHub is web sign-in/link only and cannot be used for the mobile Bearer-token handoff.
Bearer-token issuanceDoneThe server creates a hashed SyncToken, then redirects to <your-scheme>://oauth/callback?token=<bearer>.

What the iOS team needs to do

  1. Register the URL scheme interlinedlist (no ://, no path) in Xcode → app target → Info → URL Types.
  2. Use ASWebAuthenticationSession with callbackURLScheme: "interlinedlist".
  3. Pass the redirect URI on every authorize request, e.g. GET /api/auth/mastodon/authorize?instance=mastodon.social&redirect_uri=interlinedlist://oauth/callback.
  4. Extract the token from the callback URL's token query parameter and store it in the Keychain. Send it as Authorization: Bearer <token> on all subsequent API calls.

A worked Swift example is in docs/mobile-client-setup.md § 4.

What ops needs to confirm (per environment)

The allowlist is read from env at runtime, so this is a deploy-config check:

EnvironmentRequired entry in OAUTH_ALLOWED_REDIRECT_URIS
Productioninterlinedlist://oauth/callback (alongside the prod web callback)
Staging / previewinterlinedlist://oauth/callback (alongside the staging web callback)
Local dev.env.example already includes it; copy into .env.local

If the entry is missing in a given environment, authorize requests from iOS will be rejected at the /api/auth/<provider>/authorize step. This is the only realistic blocker for the iOS team.

Flow summary

iOS app
  → ASWebAuthenticationSession opens
      https://interlinedlist.com/api/auth/<provider>/authorize
        ?redirect_uri=interlinedlist://oauth/callback
  → Provider's OAuth screen
  → Server callback issues SyncToken
  → Redirect: interlinedlist://oauth/callback?token=<bearer>
  → iOS app extracts token, stores in Keychain
  → All API calls: Authorization: Bearer <token>

Multi-account & logout

The session cookie holds a comma-separated list of up to 5 session IDs, supporting multiple signed-in accounts.

MethodPathDescription
GET/api/auth/accountsList the accounts cached in the current cookie.
POST/api/auth/switchSwitch the active account in the cookie. Body: { "userId": "..." }.
POST/api/auth/remove-accountRemove one cached account from the multi-account session cookie (not a sign-out, and not an OAuth-identity unlink). Body: { "userId": "..." }. Returns { "message": "Account removed" }.
POST/api/auth/logoutSign out of the active session. Add ?all=true to sign out of every session in the cookie.

Changing your email

Initiate the change while signed in:

POST /api/user/change-email/request
Content-Type: application/json

{ "newEmail": "new@example.com", "password": "yourpassword" }

A confirmation email is sent to the new address. Complete the change with the token from that email:

POST /api/auth/verify-email-change
Content-Type: application/json

{ "token": "<token-from-email-link>" }

Full endpoint table

MethodPathDescription
POST/api/auth/loginEmail/password login; sets session cookie.
POST/api/auth/logoutSign out (?all=true for all sessions).
POST/api/auth/registerCreate a new account.
POST/api/auth/sync-tokenExchange email/password for a Bearer token.
POST/api/auth/forgot-passwordSend a password reset email.
POST/api/auth/reset-passwordComplete a password reset.
POST/api/auth/send-verification-emailResend the verification email.
POST/api/auth/verify-emailVerify email with the token from the email link.
POST/api/auth/verify-email-changeConfirm an email change.
GET/api/auth/accountsAccounts cached in the current cookie.
POST/api/auth/switchSwitch active account.
POST/api/auth/remove-accountRemove one cached account from the multi-account cookie (not an OAuth-identity unlink).
GET/api/auth/{provider}/authorizeStart OAuth sign-in or link (?link=true).
GET/api/auth/{provider}/callbackOAuth callback (redirect target, not for direct calls).
GET/api/auth/linkedin/statusWhether LinkedIn OAuth is configured.
GET/api/auth/twitter/statusWhether X (Twitter) OAuth is configured.
GET/api/auth/linkedin/org-authorizeStart LinkedIn OAuth for an organization (admin only).
  • API overview: base URL, authentication at a glance, and response conventions.
  • API explorer: try authentication endpoints live with the interactive Swagger console.