Authorization Flow
Step-by-step OAuth 2.0 authorization code flow with PKCE.
1. Build the authorize URL
Redirect the user to Better i18n's authorization endpoint:
https://dash.better-i18n.com/api/auth/mcp/authorize
?response_type=code
&client_id=<your_client_id>
&redirect_uri=<urlencoded_redirect_uri>
&scope=org:read+projects:read+keys:read+keys:write+translations:write
&state=<csrf_token>
&code_challenge=<base64url_sha256_of_verifier>
&code_challenge_method=S256Use the narrowest scope set you need. Destructive scopes (translations:publish, projects:write, glossary:write) require an explicit user click and show a yellow warning on the consent screen.
2. User consents
The user sees our consent screen where they:
- Pick one or more organizations they can administer
- Toggle individual permissions on/off
- Click Authorize
After consenting, the user lands on a "Connection complete" screen with a Continue to your app button. When they click it, we redirect to your redirect_uri with ?code=...&state=....
3. Exchange code for tokens
curl -X POST https://dash.better-i18n.com/api/auth/mcp/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=<the_code>" \
--data-urlencode "redirect_uri=<same_as_step_1>" \
--data-urlencode "client_id=<your_client_id>" \
--data-urlencode "client_secret=<your_client_secret>"client_secret is required for all confidential (server-side) clients. The server rejects the exchange without it. If you're using a PKCE-only public client, send code_verifier instead — see the SPA flow below.
Response
{
"access_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 1800,
"refresh_token": "rt_...",
"grant_id": "grt_8f3c..."
}Persist the grant_id. You need it for every installation token mint and every revoke. Store it alongside the refresh_token in your database.
Full Node.js example
// Start the flow
app.get("/connect/start", async (c) => {
const state = crypto.randomUUID();
await kv.set(`oauth_state:${state}`, { userId: c.user.id }, {
expirationTtl: 600,
});
const url = new URL(
"https://dash.better-i18n.com/api/auth/mcp/authorize",
);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", env.BETTER_I18N_CLIENT_ID);
url.searchParams.set("redirect_uri", `${env.PUBLIC_URL}/connect/callback`);
url.searchParams.set("scope", "org:read projects:read keys:write translations:write");
url.searchParams.set("state", state);
return c.redirect(url.toString());
});
// Handle callback
app.get("/connect/callback", async (c) => {
const code = c.req.query("code");
const state = c.req.query("state");
const stored = await kv.get(`oauth_state:${state}`);
if (!code || !stored) return c.text("Invalid state", 400);
await kv.delete(`oauth_state:${state}`);
const res = await fetch(
"https://dash.better-i18n.com/api/auth/mcp/token",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: `${env.PUBLIC_URL}/connect/callback`,
client_id: env.BETTER_I18N_CLIENT_ID,
client_secret: env.BETTER_I18N_CLIENT_SECRET,
}),
},
);
const tok = await res.json();
await db.insert("connections", {
userId: stored.userId,
grantId: tok.grant_id,
refreshToken: tok.refresh_token,
});
return c.redirect("/settings?connected=better-i18n");
});SPA-only PKCE flow
This section applies only to public clients (type spa or native) that cannot store a client_secret. Public clients must be explicitly requested during partner onboarding — the default is a confidential web client with a secret.
If your app has no server (browser-only SPA, Electron, etc.), run the entire exchange in the browser. PKCE makes this safe — the code_verifier never leaves the popup, and the access token is short-lived.
const BASE_URL = import.meta.env.VITE_BETTER_I18N_URL ?? "https://dash.better-i18n.com";
const CLIENT_ID = import.meta.env.VITE_BETTER_I18N_CLIENT_ID;
// 1. Generate PKCE pair, persist verifier across the popup round-trip
async function buildAuthUrl(): Promise<string> {
const verifier = crypto.randomUUID() + crypto.randomUUID();
const challenge = await sha256Base64Url(verifier);
const state = crypto.randomUUID();
localStorage.setItem("pkce", JSON.stringify({ verifier, state }));
const params = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: `${window.location.origin}/oauth/callback`,
scope: "org:read projects:read keys:read keys:write translations:write",
state,
code_challenge: challenge,
code_challenge_method: "S256",
});
return `${BASE_URL}/api/auth/mcp/authorize?${params}`;
}
// 2. Open the popup
function startConnect() {
const url = await buildAuthUrl();
window.open(url, "better-i18n-oauth", "width=520,height=720");
}
// 3. Handle callback at /oauth/callback (popup page)
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const state = params.get("state");
const stored = JSON.parse(localStorage.getItem("pkce") ?? "{}");
localStorage.removeItem("pkce");
if (!code || state !== stored.state) {
window.opener?.postMessage({ type: "oauth-error", error: "state_mismatch" }, window.location.origin);
return;
}
const res = await fetch(`${BASE_URL}/api/auth/mcp/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: `${window.location.origin}/oauth/callback`,
client_id: CLIENT_ID,
code_verifier: stored.verifier,
}),
});
if (!res.ok) {
window.opener?.postMessage({ type: "oauth-error", error: "exchange_failed" }, window.location.origin);
return;
}
const tokens = await res.json();
// Send tokens to the parent window (same-origin postMessage), then close
window.opener?.postMessage({ type: "oauth-success", tokens }, window.location.origin);
window.close();
}
async function sha256Base64Url(input: string): Promise<string> {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
return btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}Pin postMessage to your own origin — window.opener.postMessage(data, window.location.origin). A wildcard target ("*") leaks tokens to whatever site is currently in the opener tab.
In the parent window, listen for the popup's message and ship the refresh_token + grant_id to your backend. Don't keep them in localStorage long-term; persist server-side and treat anything in the browser as ephemeral.
Next step
Now mint an installation token to start calling APIs.