Authentication
Every protected request carries two credentials: an API Gateway key identifying the partner application, and a per-user Movmo-issued access token (returned as access_token from the token endpoint). The API key is issued at onboarding. The access token is minted via OAuth 2.0 PKCE against auth.e2e.movmo.io (pre-prod; prod alias is auth.movmo.io).
For the broader picture — payment card handling, traveler PII, and platform controls — see Security & data handling.
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.
How OAuth clients get provisioned
Section titled “How OAuth clients get provisioned”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 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.
For identity partners, the origins of the registered redirect URIs (plus the airline domain and its www. variant) double as the registered origins Movmo allows for CORS (the Express Checkout offer save) and as the Express Checkout return URL — see Where it can run (Express-Checkout-only partners: Where it can run on the Express Checkout guide). Register every host you will use, not only the one that completes the OAuth flow, and only domains you control durably (a released tunnel or shared-hosting subdomain would keep that trust).
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).
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.
npm install @movmo/connect-buttonPlace the one-click chip (“Checkout faster with Movmo”) on your landing page:
import { ConnectMovmoButton } from "@movmo/connect-button";
<ConnectMovmoButton clientId="your-client-id" redirectUri="https://partner.example.com/oauth/callback" isProd // add when you go live — without it the button targets Movmo's e2e environment/>;Since v0.3.0 the package defaults to Movmo’s e2e (testing) environment: every Movmo URL, including the auth.movmo.io redirect, derives from the single isProd prop, and omitting it means isProd={false}. Integrate and test without it, then add isProd for production.
On your redirect_uri page, check error first (a denied consent returns ?error=access_denied and no code), then validate state and forward code + code_verifier to your backend:
import { completeMovmoOAuth, MissingVerifierError, MovmoOAuthError, StateMismatchError,} from "@movmo/connect-button";
const params = new URLSearchParams(window.location.search);if (params.get("error")) { // Declined: show a neutral "not connected" state. Do not call completeMovmoOAuth.} else { 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 || err instanceof MissingVerifierError || (err instanceof MovmoOAuthError && err.code === "missing_code") ) { // CSRF, lost PKCE state or a callback with no code: show a retry, never success. } else { throw err; } }}The full callback page, the script-tag build (MovmoConnect.mountButton) and the backend flows are in the identity integration guide.
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.
Building your own
Section titled “Building your own”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.
- [Frontend] User triggers the connect flow from your CTA. 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,state, and optionally a space-delimitedscope(see Scopes and user consent) andresource(the resource you will call: the API URL orhttps://e2e.api.movmo.io/v1/mcp; see Token audience) as query parameters. The auth-ui parses OAuth params from the root URL — there is no/authorizesub-path. - [Movmo] A user with a Movmo session and a standing consent for your client skips this step and is redirected back immediately. Otherwise the user authenticates on Movmo-hosted pages with a one-time email code (6 digits, 5-minute expiry, same for new and returning users) or a passkey when enrolled. Your application never sees the credentials. If you requested Movmo scopes the user has not previously granted your client, a consent screen follows sign-in; a denial redirects back with
error=access_denied. - [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 Movmo-issued ES256 JWT; send asAuthorization: Bearer <access_token>),refresh_token,token_type=Bearer,expires_in=900, and — when the grant carries Movmo scopes — the effectivescopestring. Persistrefresh_tokenserver-side only; it rotates on every use (see Token refresh).
Endpoint locations come from the discovery document at /.well-known/oauth-authorization-server.
Token exchange
Section titled “Token exchange”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. The one difference: a code from the chip’s onRecognized (remembered device, @movmo/connect-button 2.0.0) is redeemed with code_verifier and without redirect_uri; see Remembered devices. See the POST /v1/oauth/token reference for the request shape.
iss | alg | JWKS |
|---|---|---|
https://e2e.api.movmo.io | ES256 | https://e2e.api.movmo.io/.well-known/jwks.json |
Token audience (aud)
Section titled “Token audience (aud)”Access tokens are resource-bound (RFC 8707). aud is always a JSON array with the API URL first:
{ "aud": ["https://e2e.api.movmo.io", "https://e2e.api.movmo.io/v1/mcp"] }| Surface | Requires aud to contain |
|---|---|
REST (/v1/*) | https://e2e.api.movmo.io |
MCP (/v1/mcp) | https://e2e.api.movmo.io/v1/mcp |
| Client type | Default aud |
|---|---|
| Onboarded partner | API URL only. Ask Movmo to add the MCP resource. |
| Self-registered (DCR) | API URL and MCP |
A token without the MCP resource is rejected by the MCP server. Verify aud as an array membership check, not a string comparison.
The resource parameter
Section titled “The resource parameter”Send resource on the authorize request, the token request, or both. Omitted at /v1/oauth/token, it inherits the authorize value; matching is exact (one optional trailing slash).
resource=https://e2e.api.movmo.io/v1/mcpA value outside your client’s allowed resources, which Movmo sets at onboarding, fails with invalid_target:
| Where | Result |
|---|---|
POST /v1/oauth/token | 400 with error=invalid_target |
| Authorization page, identity-partner clients | Redirect to your redirect_uri with error=invalid_target and your state |
| Authorization page, other clients | Movmo error page with a “Return to your app” button |
Refresh re-checks the allowed resources, so a resource Movmo removes from your client drops out of new access tokens on the next refresh, within 15 minutes (the access-token lifetime).
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 — with two exceptions that authenticate by other means: POST /v1/oauth/token (your OAuth client credentials and/or PKCE) and POST /v1/identity/resolve (your client_secret_basic credentials; see the identity integration guide). Neither requires x-api-key; sending one anyway is harmless.
Token refresh
Section titled “Token refresh”access_token lifetime is expires_in seconds (900, 15 minutes, on every grant). Refresh server-side before it expires, and persist the new refresh_token every time — it rotates on every use:
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();storedRefreshToken = tokens.refresh_token; // always store the rotated value| Session kind | Idle TTL | Absolute TTL |
|---|---|---|
| Browser (Movmo’s own UIs) | 30 days | 30 days |
| OAuth client (your integration) | 30 days | 90 days |
Reusing an already-rotated refresh_token revokes the whole session family — both grants then fail 400 invalid_grant and the user must re-authorize. A concurrent retry within a 30-second window of a legitimate rotation is treated as a lost response, not reuse.
Scopes and user consent
Section titled “Scopes and user consent”Partner-level OAuth scopes are live. A token minted with scopes is narrowed server-side to exactly those scopes on every request; the user grants them on a Movmo-hosted consent screen during the authorization step.
Requesting scopes
Section titled “Requesting scopes”Add a space-delimited scope parameter to the authorization redirect (step 2 of the PKCE flow):
https://auth.e2e.movmo.io/?response_type=code &client_id=...&redirect_uri=...&code_challenge=...&code_challenge_method=S256 &state=...&scope=profile.read%20passengers.read%20bookings.writeThe catalog is ten scopes across five domains, all machine-discoverable via scopes_supported on the discovery document (and mirrored on the RFC 9728 protected-resource metadata used by MCP clients). write does not imply read — request both when you need both:
| Scope | Grants |
|---|---|
profile.read | Read the user’s profile |
profile.write | Update profile fields, manage the profile photo |
preferences.read | Read travel, locale, and security preferences |
preferences.write | Update travel, locale, and security preferences |
passengers.read | List and read saved travelers, including travel documents |
passengers.write | Create, update, and delete saved travelers |
payments.read | List and read saved payment methods (brand + last 4 only) |
payments.write | Update and remove saved payment methods |
bookings.read | List and read the user’s bookings and trip history |
bookings.write | Search flights, build offers, select seats, and create bookings |
Unknown scope values are rejected with 400 invalid_scope. The OIDC identity scopes (openid, email, profile) may be sent alongside Movmo scopes and never trigger the consent screen.
The consent screen
Section titled “The consent screen”When the requested scopes exceed what the user has previously granted your client, the Movmo authorization UI shows a consent screen after sign-in — first-time users see the full list; returning users see only the newly requested permissions, with prior grants listed as already allowed. Each permission is its own checkbox: the user can untick individual scopes before approving rather than accepting or rejecting the whole request (see Granular consent below). Consent is per-client and incremental: approving new scopes merges with prior grants, never replaces them. If the user denies the screen outright, Movmo redirects to your redirect_uri with error=access_denied&error_description=User+denied+consent and your state — handle it as a normal OAuth denial. Requests whose scopes are already fully covered skip the screen entirely.
Granular consent
Section titled “Granular consent”Consent is granted (and withheld) per scope, not all-or-nothing. A user can approve some of the permissions your scope parameter requested and untick the rest — the resulting authorization code, and the token it exchanges for, are bound to the intersection of what you requested and what the user actually left checked (requested ∩ granted). Never assume a token carries every scope you asked for; read the token response’s scope field to find out what you actually got:
{ "access_token": "eyJraWQi...", "refresh_token": "eyJjdHkiOi...", "token_type": "Bearer", "expires_in": 900, "scope": "profile.read passengers.read"}(Here the client requested profile.read passengers.read bookings.write; the user left bookings.write unchecked, so it’s simply absent from the effective grant.) A call that needs a scope missing from that list fails 403 insufficient_scope (see Enforcement) — handle it by sending the user back through authorization for the missing scope, or by degrading the dependent feature gracefully; don’t assume the scope was granted just because you requested it.
Two more behaviors follow from per-scope consent:
- No consent-screen loop. A scope the user has previously declined for your client is not re-offered on a later authorization — Movmo issues the code without it, silently, rather than prompting again. Only scopes the user has never decided on (not yet granted, not yet declined) trigger the consent screen. A declined scope stays declined until the user restores it themselves from Connected apps (see Revocation).
- All-declined fails the request. If the user unchecks every scope you requested, there is nothing left to authorize and the code request fails with
400 invalid_scoperather than issuing a scope-less code.
The token response scope field
Section titled “The token response scope field”When a grant carries scopes, the token response (both grants) echoes the effective scope set per RFC 6749 §5.1:
{ "access_token": "eyJraWQi...", "refresh_token": "eyJjdHkiOi...", "token_type": "Bearer", "expires_in": 900, "scope": "profile.read passengers.read bookings.write"}Always read this field rather than assuming your requested scopes were granted in full — the effective set is the intersection of your request and the user’s current consent. Refresh preserves the original grant’s scopes and never escalates them; if the user has revoked consent since issuance, the refresh grant fails with 400 invalid_scope and error_description telling you to restart the authorization flow.
Enforcement
Section titled “Enforcement”Every API request made with a scoped token is checked against the granted scopes. A request outside them returns 403 with an RFC 6750 error body:
{ "error": "insufficient_scope", "error_description": "the token's granted scopes do not allow this request", "scope": "payments.read"}scope names the scope the operation requires — request it (the consent screen will show only the delta) and retry with the new token. The RFC 6750 WWW-Authenticate challenge is also set; note that API Gateway delivers it as x-amzn-remapped-www-authenticate. Administrative and internal operations sit outside the scope catalog entirely and are not reachable with a scoped token, regardless of the user’s own permissions.
Revocation
Section titled “Revocation”Users can revoke your application’s access at any time from the Connected apps page in their Movmo account settings. Revocation takes effect within minutes: the user’s active session tokens are invalidated immediately (a refresh attempt returns 400 invalid_grant), and even after that window your refresh grant fails with 400 invalid_scope because the underlying consent is gone. Any still-valid access token stops working when it reaches its own 15-minute expiry. Treat both 400 responses on the refresh grant as “re-authorize from scratch” — send the user back through the authorization flow, where they will see the consent screen again. There is no separate revocation webhook; the token-endpoint error is the signal.
Per-permission management is separate from full revocation. From the same Connected apps page, users can also toggle individual permissions off or back on for a client without disconnecting it — declining one previously-granted scope, or restoring one they’d declined, while the rest of the grant stays intact. This doesn’t invalidate the current access token outright; it takes effect on the client’s next token refresh, where the narrowed (or restored) scope set shows up in the response scope field. Don’t cache a grant’s scope set beyond a single refresh cycle — always trust what the latest token response says over what you last saw.
Tokens without scopes
Section titled “Tokens without scopes”What happens when the scope parameter is omitted depends on how your client was registered:
- Dynamically-registered clients (RFC 7591, including MCP agents): omitting
scope— or sending only the OIDC identity scopes — is treated as requesting the full ten-scope catalog. The user sees the consent screen for all ten permissions, and the issued token is narrowed and enforced exactly as if you had requested them explicitly (the token response echoes the effectivescope). To request less, send an explicitscope— least-privilege tokens are safer for your users and show a shorter consent screen. - Pre-registered partner clients (provisioned with Movmo during onboarding): omitting
scopekeeps the legacy behavior — the token carries no scope set, no consent screen is shown, and requests are authorized by the user’s own permissions alone. New integrations should still request explicit scopes; the un-narrowed path exists for backward compatibility.
Grants issued before scopes existed continue to refresh without narrowing; the new behavior applies from each client’s next full authorization.