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).
What you need
Section titled “What you need”You will end up holding three distinct credentials. They are issued by different systems, rotate independently, and serve different purposes — do not conflate them.
| Credential | What it identifies | Used at | Issued 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_secret | Your 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_token | A 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_secretsecurely in a backend secret store. The token endpoint validates it viaclient_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 omitclient_secretfrom the token exchange — PKCE’scode_verifieris 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 withinvalid_redirect_uri. - Must not be
localhost(or127.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.
OAuth 2.0 PKCE flow
Section titled “OAuth 2.0 PKCE flow”The flow has three actors: your frontend, your backend (where client_secret lives), and Movmo’s authorization server (auth.e2e.movmo.io).
- [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 viacrypto.getRandomValues) and derivecode_challenge = base64url(sha256(code_verifier)). Holdcode_verifierand a CSRFstateinsessionStoragefor the redirect round-trip; delete after the token exchange completes. - [Frontend → Movmo] Redirect to the
authorization_endpointfrom the discovery document (currentlyhttps://auth.e2e.movmo.io/) withresponse_type=code,client_id,redirect_uri,code_challenge,code_challenge_method=S256, andstateas query parameters. The auth-ui parses OAuth params from the root URL — there is no/authorizesub-path. - [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
SignUpflow) 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. - [Movmo → Frontend] Movmo redirects to your
redirect_uriwithcodeand the originalstate. Validatestatematches what you issued. - [Frontend → Backend] The frontend forwards
codeandcode_verifierto your backend. - [Backend → Movmo] Your backend POSTs to
https://e2e.api.movmo.io/v1/oauth/tokenwithgrant_type=authorization_code,client_id,client_secret(confidential clients only),code,code_verifier, andredirect_uri. Must run server-side —client_secretcannot reach a browser. Public clients (DCR-registered withtoken_endpoint_auth_method=none) omitclient_secret. - [Backend] Movmo returns
access_token(a Cognito ID token; send asAuthorization: Bearer <access_token>),refresh_token,token_type=Bearer, andexpires_in=900. Persistrefresh_tokenserver-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.
Making authenticated requests
Section titled “Making authenticated requests”REST and MCP have different header requirements:
- REST (
/v1/*excluding/v1/mcp): bothx-api-keyANDAuthorization: Bearer <access_token>are required on every protected route. - MCP (
/v1/mcp):Authorization: Bearer <access_token>only. Do NOT sendx-api-key— partner usage plans throttle keyed traffic on MCP routes to 0 RPS as a safety net for misuse, returning a429. 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)}API-key-only routes
Section titled “API-key-only routes”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:
| Method | Path |
|---|---|
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.
Token refresh
Section titled “Token refresh”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.
Scopes (planned)
Section titled “Scopes (planned)”Partner-level OAuth scopes are tracked under MOVMO-346. Until that ships, a partner token carries the user’s full RBAC permissions.