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.

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).

  1. [Frontend] User triggers the connect flow from a CTA you build — Movmo does not ship a branded OAuth button today. 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. Use a standards-compliant OAuth client library — do not hand-roll the flow.

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.