Skip to content

Authentication

Every protected request carries two credentials: an API Gateway key identifying the partner application, and a per-user Cognito ID token (returned as access_token from the token endpoint). The API key is issued at onboarding. The ID token is minted via OAuth 2.0 PKCE against auth.e2e.movmo.io (pre-prod; prod alias is auth.movmo.io).

You will end up holding three distinct credentials. They are issued by different systems, rotate independently, and serve different purposes — do not conflate them.

CredentialWhat it identifiesUsed atIssued by
API key (x-api-key)Your partner application to API Gateway. Drives rate limit, route allowlist, usage plan.x-api-key header on every REST call. Never sent on Model Context Protocol (MCP) calls.Movmo (provisioned in API Gateway at partner onboarding)
client_id + client_secretYour OAuth client to the token endpoint. Standard OAuth 2.0 confidential-client credentials.Sent to POST /v1/oauth/token either in the form body or as Authorization: Basic base64(client_id:client_secret).Movmo (provisioned at OAuth client registration; pre-registered for confidential partners like Aviare)
access_token + refresh_tokenA specific end-user’s session, scoped to your client.Authorization: Bearer <access_token> on REST and MCP. Refresh via the token endpoint.Movmo’s authorization server, returned from the PKCE exchange

API key and client_secret are not the same thing. The API key controls whether your application can talk to the gateway at all. client_secret controls whether your OAuth client can mint a user token. A leaked API key lets an attacker hit the gateway as your app; a leaked client_secret lets an attacker swap codes for tokens as your client. Store them separately.

Public vs confidential clients:

  • Confidential clients (server-to-server OAuth, what Aviare and other commercial partners use) hold client_secret securely in a backend secret store. The token endpoint validates it via client_secret_basic.
  • Public clients (DCR-registered MCP agents, browser/native PKCE clients) cannot hold a secret. They register with token_endpoint_auth_method: "none" and omit client_secret from the token exchange — PKCE’s code_verifier is the proof-of-possession instead.

Dynamic Client Registration (RFC 7591) is reserved for autonomous MCP agents and is the public-client path.

Two distinct paths, depending on what kind of integration you are:

Named commercial partners (airlines, OTAs, TMCs, hotel cross-sells — anyone with backend infrastructure and a long-lived integration) are pre-registered by Movmo. You will receive a stable client_id and a client_secret over a secure channel at onboarding. There is no TTL — your credentials do not silently expire. Rotation is coordinated explicitly between you and Movmo.

Autonomous MCP agents (Claude Desktop, Cursor, ChatGPT connectors, custom MCP clients) self-register at POST /v1/oauth/register per RFC 7591 Dynamic Client Registration. Each install gets a fresh server-assigned UUID client_id. Public clients (token_endpoint_auth_method: "none") authenticate via PKCE alone; confidential clients receive a client_secret. Registrations carry a sliding 30-day TTL that extends on every successful token exchange — abandoned installs eventually expire and any refresh tokens they hold are invalidated.

If you are building a backend integration for a commercial partnership, contact Movmo for pre-registration. Do not register via DCR — your integration deserves a stable identifier and no TTL.

Confidential client redirect URI requirements (verified at registration time):

  • Must use https://http:// is rejected with invalid_redirect_uri.
  • Must not be localhost (or 127.0.0.1, etc.) — local development URIs are rejected. Use a real partner-controlled domain or a tunnel service that exposes HTTPS.

Public clients (PKCE, token_endpoint_auth_method: "none") are allowed http://localhost:* redirect URIs for local development.

The flow has three actors: your frontend, your backend (where client_secret lives), and Movmo’s authorization server (auth.e2e.movmo.io).

React frontends — use @movmo/connect-button

Section titled “React frontends — use @movmo/connect-button”

For React partners, install the @movmo/connect-button package. It bundles the parts of the flow that every partner integration repeats: code_verifier generation, code_challenge derivation via crypto.subtle, state issuance, sessionStorage round-trip, and the redirect to auth.movmo.io.

Terminal window
npm install @movmo/connect-button

Drop the button onto your sign-in page:

import { ConnectMovmoButton } from "@movmo/connect-button";
<ConnectMovmoButton
clientId="your-client-id"
redirectUri="https://partner.example.com/oauth/callback"
/>;

On your redirect_uri page, validate state and forward code + code_verifier to your backend:

import { completeMovmoOAuth, StateMismatchError } from "@movmo/connect-button";
const params = new URLSearchParams(window.location.search);
try {
const { code, codeVerifier } = await completeMovmoOAuth(
params.get("code"),
params.get("state"),
);
// POST { code, codeVerifier } to your backend, which calls /v1/oauth/token
// with client_secret server-side.
} catch (err) {
if (err instanceof StateMismatchError) {
// CSRF — refuse and start a fresh flow.
}
throw err;
}

For partners with their own CTA component or non-button surfaces (custom modals, mobile webviews), the package also exports a headless useMovmoOAuth() hook that returns { startOAuth, isStarting } with the same PKCE machinery.

The package never holds client_secret — that credential cannot reach a browser. The token exchange always runs server-side; see Token exchange below.

Partners not on React (Vue, Svelte, plain JS, native mobile) implement the same seven steps directly. Use a standards-compliant OAuth client library — do not hand-roll the flow.

  1. [Frontend] User triggers the connect flow from your CTA. Generate a code_verifier (43–128 random chars via crypto.getRandomValues) and derive code_challenge = base64url(sha256(code_verifier)). Hold code_verifier and a CSRF state in sessionStorage for the redirect round-trip; delete after the token exchange completes.
  2. [Frontend → Movmo] Redirect to the authorization_endpoint from the discovery document (currently https://auth.e2e.movmo.io/) with response_type=code, client_id, redirect_uri, code_challenge, code_challenge_method=S256, and state as query parameters. The auth-ui parses OAuth params from the root URL — there is no /authorize sub-path.
  3. [Movmo] The user authenticates on Movmo-hosted pages with an email or SMS OTP. Your application never sees the credentials. OTP length differs by user state: a brand-new user (Cognito SignUp flow) receives a 6-digit code; a returning user (Cognito custom-challenge flow) receives an 8-digit code. Build any custom OTP UI to handle both.
  4. [Movmo → Frontend] Movmo redirects to your redirect_uri with code and the original state. Validate state matches what you issued.
  5. [Frontend → Backend] The frontend forwards code and code_verifier to your backend.
  6. [Backend → Movmo] Your backend POSTs to https://e2e.api.movmo.io/v1/oauth/token with grant_type=authorization_code, client_id, client_secret (confidential clients only), code, code_verifier, and redirect_uri. Must run server-side — client_secret cannot reach a browser. Public clients (DCR-registered with token_endpoint_auth_method=none) omit client_secret.
  7. [Backend] Movmo returns access_token (a Cognito ID token; send as Authorization: Bearer <access_token>), refresh_token, token_type=Bearer, and expires_in=900. Persist refresh_token server-side only.

Endpoint locations come from the discovery document at /.well-known/oauth-authorization-server.

Whether you use @movmo/connect-button or hand-roll the flow, the token exchange itself is identical and always runs on your backend with client_secret. See the POST /v1/oauth/token reference for the request shape.

REST and MCP have different header requirements:

  • REST (/v1/* excluding /v1/mcp): both x-api-key AND Authorization: Bearer <access_token> are required on every protected route.
  • MCP (/v1/mcp): Authorization: Bearer <access_token> only. Do NOT send x-api-key — partner usage plans throttle keyed traffic on MCP routes to 0 RPS as a safety net for misuse, returning a 429. JWT verification happens at the MCP server, not the gateway.

REST example, JavaScript:

const res = await fetch("https://e2e.api.movmo.io/v1/users/me", {
headers: {
"x-api-key": process.env.MOVMO_API_KEY,
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) throw new Error(`Movmo ${res.status}: ${await res.text()}`);
const profile = await res.json();

Go:

client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET",
"https://e2e.api.movmo.io/v1/users/me", nil)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("x-api-key", os.Getenv("MOVMO_API_KEY"))
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("call movmo: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("movmo %d: %s", resp.StatusCode, body)
}

Three flights-catalog routes accept the API key alone, with no user JWT. They are booking-flow entry points used before a user session exists:

MethodPath
GET/v1/flights/providers/{provider}/capabilities
GET/v1/flights/providers/{provider}/offers/{offer_id}
POST/v1/flights/providers/{provider}/offers

Every other route requires both headers.

access_token lifetime is expires_in seconds (900 on initial exchange). Refresh server-side; on a non-2xx, keep the existing refresh_token:

const res = await fetch("https://e2e.api.movmo.io/v1/oauth/token", {
method: "POST",
headers: {
"x-api-key": process.env.MOVMO_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "refresh_token",
client_id: "aviare-prod",
client_secret: process.env.MOVMO_CLIENT_SECRET,
refresh_token: storedRefreshToken,
}),
});
if (!res.ok) throw new Error(`Refresh failed: ${res.status}`);
const tokens = await res.json();

Refresh tokens are not rotated. The refresh response returns only a new access_token — no new refresh_token — and the original refresh_token continues to work until its TTL expires (Cognito default: 30 days). Persist the refresh_token once at the initial code exchange and reuse it for the lifetime of the user session.

Partner-level OAuth scopes are tracked under MOVMO-346. Until that ships, a partner token carries the user’s full RBAC permissions.