baff.gg Docs Developer Portal

For websites

OAuth2 and Login with baff.gg

Let people sign in to your site with their baff.gg account, read their servers, add them to servers, and add your bot, with OAuth2 that works like Discord's.

OAuth2 lets your website or app act for a baff.gg user, with their permission and only as far as they allow. Use it to:

  • Log people in with their baff.gg account ("Login with baff.gg"), instead of making them sign up again.
  • See their servers, for example to show a dashboard only for servers they manage.
  • Add them to a server your bot is in (guilds.join), for example after they buy access on your site.
  • Add your bot to a server and sign the person in, in one step (bot).

It's the standard OAuth2 authorization code flow, with PKCE, shaped like Discord's. If you already have "Login with Discord", you mostly change the URLs; see Differences from Discord.

Set up your application

Every application in the Developer Portal is also an OAuth2 client. Open it, then OAuth2:

  • Client ID: the application ID. It's public; it goes in links.
  • Client Secret: proves a request comes from your server. Make Secret shows it once; we only keep a fingerprint, so copy it then. Reset Secret makes a new one and the old one stops working at once (people already signed in stay signed in).
  • Redirects: the URLs baff.gg may send people back to, up to 10. They're matched exactly, character for character, including the path, the query and a trailing slash. They must use https://; plain http:// only works for localhost and 127.0.0.1 while you develop.
  • Public Client: turn this on for apps that can't keep a secret (a single-page site, a phone or desktop app). They sign in with PKCE instead of the secret, and every authorize link must carry a code_challenge.
  • URL Generator: pick scopes and a redirect to get a ready authorize link.

The flow

  1. Your site sends the person to baff.gg's authorize link, with the scopes you need and a random state.
  2. They log in to baff.gg if they aren't already, and see a consent screen: your app's name and icon, what each scope allows, and which site they'll go back to. They click Authorize or Cancel.
  3. baff.gg sends them to your redirect URI with a one-time code and your state.
  4. Your server checks state, then trades the code for an access token and a refresh token at the token endpoint.
  5. You call the API with Authorization: Bearer <access token>, for example GET /api/users/@me to learn who they are.
text
https://baff.gg/oauth2/authorize?response_type=code&client_id=YOUR_CLIENT_ID&scope=identify%20email&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&state=RANDOM_STATE
Parameter
response_typeAlways code.
client_idYour application ID.
redirect_uriOne of your registered redirects, exactly. You can leave it out if you registered only one.
scopeSpace-separated scopes (%20 or + in the URL).
stateA random value you check when they come back (up to 1024 characters). It stops other sites from feeding your callback their own codes. Always use it.
promptconsent (default) always shows the screen. none skips it when the person already allowed every scope you ask for (never with bot); otherwise the screen shows as usual.
code_challengePKCE: base64url SHA-256 of your verifier (43 characters). Required for public clients, optional otherwise.
code_challenge_methodS256 (the only one supported) when you send code_challenge.
permissionsWith scope bot: the bot's permissions number, like on the Installation page.
guild_idWith scope bot: the server to pick first in the server list.

If the client_id doesn't exist or the redirect_uri isn't registered, baff.gg shows an error page and never redirects, so your link can't be used to bounce people to someone else's site. Other mistakes go back to your redirect URI as an error (below).

What comes back

When they click Authorize:

text
https://example.com/callback?code=6a1f...&state=RANDOM_STATE

When they click Cancel, or the link was wrong:

text
https://example.com/callback?error=access_denied&error_description=The+person+cancelled.&state=RANDOM_STATE
errorWhy
access_deniedThey clicked Cancel.
invalid_scopeAn unknown scope, no scope, or bot on an app without a bot.
unsupported_response_typeresponse_type isn't code.
invalid_requestA bad code_challenge, a public client without one, or a bad permissions number.

A code works once, for 10 minutes, only for your client, only with the same redirect_uri, and (with PKCE) only with the matching verifier.

2. Trade the code for tokens

http
POST /api/oauth2/token HTTP/1.1
Host: baff.gg
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(CLIENT_ID:CLIENT_SECRET)

grant_type=authorization_code&code=6a1f...&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback
bash
curl -X POST https://baff.gg/api/oauth2/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  --data-urlencode redirect_uri=https://example.com/callback

The endpoint takes a form body (application/x-www-form-urlencoded) or JSON (application/json), and the client credentials either as HTTP Basic (above) or as client_id and client_secret in the body. Not both. Public clients send only client_id and the code_verifier.

json
{
	"access_token": "bfxo_9c41...",
	"token_type": "Bearer",
	"expires_in": 604800,
	"refresh_token": "bfxr_77d0...",
	"scope": "identify email"
}
  • access_token lasts 7 days (expires_in is in seconds).
  • refresh_token gets you a new pair (below). It lasts 90 days from when it was made, and each refresh starts a new 90 days.
  • With scope bot, the answer also has "guild": {"id", "name", "icon"}: the server the bot was added to.

Keep both tokens on your server, never in a URL or a log. We only store fingerprints of them.

Errors follow the OAuth2 spec: {"error": "...", "error_description": "..."}.

StatuserrorWhy
400invalid_requestA missing parameter, or the secret sent twice.
400invalid_grantThe code is unknown, used, expired, for another client or redirect; the PKCE verifier doesn't match; or the person deauthorized your app since.
400unsupported_grant_typeOnly authorization_code and refresh_token.
401invalid_clientUnknown client, wrong secret, or no secret from a confidential client.
429rate_limitedSlow down (see Limits).

3. Use the access token

bash
curl https://baff.gg/api/users/@me -H "Authorization: Bearer $ACCESS_TOKEN"
json
{
	"id": "103600000000000000",
	"username": "andre",
	"global_name": "Andre",
	"discriminator": "0",
	"avatar": "https://cdn.baff.gg/avatars/103600000000000000/a1b2c3.webp",
	"banner": null,
	"accent_color": 3065014,
	"bot": false,
	"tag": null,
	"email": "andre@example.com",
	"verified": true
}

Use id to recognise the person: it never changes, while usernames can. avatar and banner are full image URLs (or null). tag is baff.gg's account label: null for most people, or official, staff, bot, verified_bot. email and verified are only there with the email scope.

Bearer tokens work only on these routes; anywhere else they get 401 bearer_not_allowed:

RouteScopeReturns
GET /api/users/@meidentify (email adds the address)The user, above
GET /api/users/@me/guildsguildsEvery server they're in
GET /api/users/@me/guilds/{serverId}/memberguilds.members.readTheir member info in one server
GET /api/oauth2/@meanyWhat this token is

/api/users/@me/servers works the same as /api/users/@me/guilds.

json
[
	{
		"id": "103700000000000123",
		"name": "Lounge",
		"icon": "https://cdn.baff.gg/server-icons/103700000000000123/9f8e.webp",
		"banner": null,
		"owner": true,
		"permissions": "17179869183",
		"features": ["verified"]
	}
]

permissions is their server-wide permissions number as a string (owners and Administrators get every bit; see Permissions). features lists the server's types: partnered, verified, official.

json
{
	"user": { "id": "103600000000000000", "username": "andre", "global_name": "Andre", "...": "..." },
	"nick": "dre",
	"avatar": null,
	"roles": ["103700000000000456"],
	"joined_at": "2026-10-02T18:04:11Z",
	"pending": false,
	"deaf": false,
	"mute": false,
	"flags": 0,
	"communication_disabled_until": null
}

That's GET /api/users/@me/guilds/{serverId}/member. It answers 404 unknown_server when they aren't in that server. pending is true while they haven't agreed to the server's rules (Rules Screening). communication_disabled_until is the end of a timeout, if they're timed out.

GET /api/oauth2/@me returns {application: {id, name, icon, description, bot_public}, scopes, expires, user} (user only with identify).

StatuscodeWhy
401invalid_tokenUnknown, expired or revoked, or the account can't be used right now (suspended or being deleted).
401bearer_not_allowedA Bearer token on a route that doesn't take one.
403missing_scopeThe token doesn't have the scope this route needs.

These routes allow cross-origin requests (Access-Control-Allow-Origin: *) so browser apps can call them; they never use cookies.

Refresh and revoke

When the access token runs out, trade the refresh token for a new pair. The old pair stops working at once, so always keep the newest refresh token.

bash
curl -X POST https://baff.gg/api/oauth2/token -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=refresh_token -d refresh_token="$REFRESH_TOKEN"

When someone logs out of your site, revoke their tokens. Send either token; both stop working. The answer is 200 {} whether or not the token existed.

bash
curl -X POST https://baff.gg/api/oauth2/token/revoke -u "$CLIENT_ID:$CLIENT_SECRET" -d token="$ACCESS_TOKEN"

People can also remove your app any time in User Settings > Authorized Apps. That deletes every token and unused code you have for them at once; your next call gets 401 invalid_token and a refresh gets invalid_grant. Send them through the authorize link again.

PKCE

PKCE proves the app that started the sign-in is the one finishing it. It's required for public clients and a good idea for everyone.

  1. Make a verifier: 43 to 128 random characters from A-Z a-z 0-9 - . _ ~. Keep it (in the session, or in the browser for a public client).
  2. Make the challenge: base64url (no padding) of the SHA-256 of the verifier.
  3. Add code_challenge=<challenge>&code_challenge_method=S256 to the authorize link.
  4. Send code_verifier=<verifier> with the code to the token endpoint.
js
import crypto from 'node:crypto';

const verifier = crypto.randomBytes(32).toString('base64url'); // 43 characters
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');

In a browser, crypto.getRandomValues and crypto.subtle.digest('SHA-256', ...) do the same. A public client then calls the token endpoint from the page with client_id, code, redirect_uri and code_verifier, no secret.

Scopes

ScopeLets youConsent screen says
identifyGET /users/@me without the emailSee your username, display name, avatar and banner
emailAdds email and verified to /users/@meSee your email address
guildsGET /users/@me/guildsSee which servers you are in
guilds.members.readGET /users/@me/guilds/{id}/memberRead your member info in your servers
guilds.joinAdd them to servers your bot is inJoin servers for you
botAdds your bot to a server they pick (they need Manage Server there)Add a bot to a server you manage
applications.commandsNothing extra: your bot's slash commands already show up wherever it isUse its commands

Ask only for what you need: people are more likely to say yes, and you have less to protect. baff.gg has no connected accounts, so there's no connections scope (asking for it is invalid_scope).

A person who authorizes again with more scopes adds to what your app already has. With prompt=none, someone who already allowed all of the scopes you ask for goes straight back to your site with a new code.

Add your bot and sign in at once (bot)

Add bot to the scopes (and permissions) and the consent screen also asks which server to add your bot to, with the permissions they can untick, exactly like the install link. They need Manage Server in that server. Your redirect gets two more parameters:

text
https://example.com/callback?code=...&state=...&guild_id=103700000000000123&permissions=3072

permissions is what they actually gave. The token answer includes guild. A plain install link without response_type=code still works as before and doesn't sign anyone in.

Add people to a server (guilds.join)

With an access token that has guilds.join, your bot can add that person to a server it's in, like Discord's Add Guild Member:

http
PUT /api/guilds/{serverId}/members/{userId} HTTP/1.1
Host: baff.gg
Authorization: Bot bfx_...
Content-Type: application/json

{ "access_token": "bfxo_..." }

/api/servers/{serverId}/members/{userId} works too.

  • Use your bot token (Authorization: Bot), and the person's access token in the body. The access token must belong to your app, have guilds.join, and be for userId.
  • Your bot must be in the server, with Create Invite.
  • Everything a normal join checks still applies: banned people can't join (403 banned), a server with paused invites takes nobody (403 invites_paused), nobody can be in more than 100 servers (400 too_many_servers), Rules Screening and onboarding work as usual. Apps can't add people to baff.gg team servers (403 team_server).
  • Answers 201 with the member when they joined, 204 when they were already a member. They see the server appear in their list right away, and the server's audit log records it as Add Member (App) by your bot.
  • 30 adds per minute per app.
StatuscodeWhy
403bot_token_requiredNot called with an application's bot token.
403invalid_access_tokenUnknown, expired or revoked, or from another app.
403missing_scopeThe access token has no guilds.join.
403token_user_mismatchThe access token is for someone else.
403missing_permissionYour bot doesn't have Create Invite there.
404unknown_serverYour bot isn't in that server.
js
// After the OAuth2 callback, with the person's tokens in hand:
const res = await fetch(`https://baff.gg/api/guilds/${SERVER_ID}/members/${user.id}`, {
	method: 'PUT',
	headers: { Authorization: `Bot ${process.env.BOT_TOKEN}`, 'Content-Type': 'application/json' },
	body: JSON.stringify({ access_token: tokens.access_token })
});
if (res.status === 201) console.log('joined', await res.json());
else if (res.status === 204) console.log('already a member');
else console.error('could not add them', res.status, await res.json());

Example: Login with baff.gg in Node

A complete site in one file, using only Node 18 or newer (no packages). It shows a Login button, sends people to baff.gg, handles the callback, and greets them by name. It uses state tied to a cookie and PKCE, so it works as a confidential client (with a secret) or a public one (without).

  1. In the portal's OAuth2 page, add the redirect http://localhost:3000/callback and Make Secret (or turn on Public Client).
  2. Save this as login-with-bafflux.mjs, then run CLIENT_ID=... CLIENT_SECRET=... node login-with-bafflux.mjs and open http://localhost:3000.
js
import http from 'node:http';
import crypto from 'node:crypto';

const BAFFLUX = process.env.BAFFLUX_URL ?? 'https://baff.gg';
const CLIENT_ID = process.env.CLIENT_ID;
const CLIENT_SECRET = process.env.CLIENT_SECRET ?? ''; // empty for a public client
const PORT = Number(process.env.PORT ?? 3000);
const REDIRECT_URI = `http://localhost:${PORT}/callback`;
const SCOPES = 'identify email guilds';

// Sign-ins in progress (state -> PKCE verifier) and signed-in visitors (session -> tokens + user).
// A real site keeps these in its database.
const pending = new Map();
const sessions = new Map();

const random = () => crypto.randomBytes(32).toString('base64url');
const escape = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);

function cookies(req) {
	return Object.fromEntries((req.headers.cookie ?? '').split(';').filter(Boolean).map((c) => c.trim().split('=').map(decodeURIComponent)));
}

function page(res, html, headers = {}) {
	res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...headers });
	res.end(`<!doctype html><meta name="viewport" content="width=device-width"><body style="font-family:sans-serif;margin:40px">${html}</body>`);
}

function redirect(res, location, headers = {}) {
	res.writeHead(302, { Location: location, ...headers });
	res.end();
}

async function token(params) {
	const body = new URLSearchParams({ ...params, client_id: CLIENT_ID });
	const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
	if (CLIENT_SECRET) headers.Authorization = 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64');
	const res = await fetch(`${BAFFLUX}/api/oauth2/token`, { method: 'POST', headers, body });
	const data = await res.json();
	if (!res.ok) throw new Error(`${data.error}: ${data.error_description}`);
	return data;
}

async function revoke(value) {
	const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
	if (CLIENT_SECRET) headers.Authorization = 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64');
	await fetch(`${BAFFLUX}/api/oauth2/token/revoke`, { method: 'POST', headers, body: new URLSearchParams({ token: value, client_id: CLIENT_ID }) });
}

async function api(path, accessToken) {
	const res = await fetch(`${BAFFLUX}/api${path}`, { headers: { Authorization: `Bearer ${accessToken}` } });
	if (!res.ok) throw new Error(`${path}: ${res.status}`);
	return res.json();
}

http
	.createServer(async (req, res) => {
		const url = new URL(req.url, `http://localhost:${PORT}`);
		const session = sessions.get(cookies(req).sid);
		try {
			if (url.pathname === '/') {
				if (!session) return page(res, `<h1>My site</h1><a href="/login">Login with baff.gg</a>`);
				const { user, guilds } = session;
				return page(
					res,
					`<h1>Hi, ${escape(user.global_name ?? user.username)}</h1>
					 <p>${escape(user.email ?? '')}</p>
					 <p>You're in ${guilds.length} servers: ${guilds.map((g) => escape(g.name)).join(', ')}</p>
					 <a href="/logout">Log out</a>`
				);
			}

			if (url.pathname === '/login') {
				const state = random();
				const verifier = random();
				const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
				pending.set(state, verifier);
				const authorize = new URL('/oauth2/authorize', BAFFLUX);
				authorize.search = new URLSearchParams({
					response_type: 'code',
					client_id: CLIENT_ID,
					redirect_uri: REDIRECT_URI,
					scope: SCOPES,
					state,
					code_challenge: challenge,
					code_challenge_method: 'S256'
				});
				// The state also goes in a cookie, so a callback only counts in the browser that started it.
				return redirect(res, authorize.href, { 'Set-Cookie': `oauth_state=${state}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600` });
			}

			if (url.pathname === '/callback') {
				const state = url.searchParams.get('state');
				const verifier = pending.get(state);
				if (!state || state !== cookies(req).oauth_state || !verifier) return page(res, 'That sign-in link expired. <a href="/login">Try again</a>');
				pending.delete(state);
				if (url.searchParams.get('error')) return page(res, `Sign-in cancelled. <a href="/">Back</a>`);

				const tokens = await token({
					grant_type: 'authorization_code',
					code: url.searchParams.get('code'),
					redirect_uri: REDIRECT_URI,
					code_verifier: verifier
				});
				const [user, guilds] = await Promise.all([api('/users/@me', tokens.access_token), api('/users/@me/guilds', tokens.access_token)]);
				const sid = random();
				sessions.set(sid, { tokens, user, guilds });
				return redirect(res, '/', { 'Set-Cookie': [`sid=${sid}; HttpOnly; SameSite=Lax; Path=/`, 'oauth_state=; Path=/; Max-Age=0'] });
			}

			if (url.pathname === '/logout') {
				if (session) {
					sessions.delete(cookies(req).sid);
					await revoke(session.tokens.access_token).catch(() => {}); // the refresh token goes with it
				}
				return redirect(res, '/', { 'Set-Cookie': 'sid=; Path=/; Max-Age=0' });
			}

			res.writeHead(404).end();
		} catch (err) {
			console.error(err.message); // never log tokens
			page(res, `Something went wrong. <a href="/">Back</a>`);
		}
	})
	.listen(PORT, () => console.log(`Open http://localhost:${PORT}`));

Login with baff.gg buttons

You may use Baff, our mascot, and the name baff.gg on a sign-in button. Keep them recognisable:

  • Say Login with baff.gg, Sign in with baff.gg or Continue with baff.gg. baff.gg with a capital B, one word.
  • Use Baff as he is: baff.svg (teal #2EC4B6, white face). Don't stretch, rotate, outline or recolor him, and don't add anything to him. On a teal background, draw him white with a teal face instead, so he doesn't disappear.
  • Make Baff at least 20px tall, with at least 8px of space around him.
  • Pick one of the two looks below. Text on teal must be dark (#04201D): white on teal is hard to read.
  • Don't make it look like baff.gg made or endorses your app.
html
<a class="bafflux-login" href="/login">
	<svg viewBox="115 36 800 910" width="22" height="22" aria-hidden="true">
		<g fill="#2EC4B6">
			<path d="M 155 614 C 125 344 335 194 545 212 C 785 230 905 434 860 644 C 845 770 800 839 761 884 C 731 920 695 872 683 836 C 671 896 641 938 605 902 C 581 872 569 848 551 860 C 515 914 461 920 437 872 C 419 836 407 854 389 884 C 359 938 311 920 293 872 C 269 830 245 854 221 824 C 173 770 161 692 155 614 Z" />
			<path d="M 515 218 C 503 152 551 116 581 98" fill="none" stroke="#2EC4B6" stroke-width="30" stroke-linecap="round" />
			<circle cx="593" cy="80" r="38" />
		</g>
		<g fill="#FFFFFF">
			<ellipse cx="400" cy="480" rx="44" ry="74" />
			<ellipse cx="600" cy="480" rx="44" ry="74" />
			<path d="M 410 590 L 590 590 C 590 660, 548 706, 500 706 C 452 706, 410 660, 410 590 Z" />
		</g>
	</svg>
	Login with baff.gg
</a>

<style>
	.bafflux-login {
		display: inline-flex;
		align-items: center;
		gap: 10px;
		height: 44px;
		padding: 0 18px;
		border-radius: 10px;
		font: 600 16px system-ui, sans-serif;
		text-decoration: none;
		/* Dark: */
		background: #101315;
		color: #ffffff;
		/* Light instead: background: #ffffff; color: #04201d; box-shadow: inset 0 0 0 1px #c9d3d3; */
	}
	.bafflux-login:hover { background: #1e2327; }
	.bafflux-login:focus-visible { outline: 3px solid #2ec4b6; outline-offset: 2px; }
</style>

Differences from Discord

Most "Login with Discord" code ports by changing https://discord.com to https://baff.gg (and /oauth2/authorize stays the same path). The differences:

Discordbaff.gg
Authorize pagediscord.com/oauth2/authorizebaff.gg/oauth2/authorize
Token endpointsdiscord.com/api/oauth2/token, /token/revokebaff.gg/api/oauth2/token, /token/revoke
API basediscord.com/api/v10baff.gg/api (no version)
avatar, banner, server iconImage hashes you build CDN URLs fromFull image URLs
discriminator"0"Always "0"
tag on usersNot therebaff.gg's account label (official, staff, bot, verified_bot, or null)
Server featuresDiscord's feature flagsbaff.gg's server types (partnered, verified, official)
connections, rpc, webhook.incoming, activities scopesExistNot supported (invalid_scope)
client_credentials grantSupported for the app ownerNot supported (use your bot token)
Redirect schemesCustom schemes allowedhttps, or http on localhost/127.0.0.1 only
PKCEOptionalOptional, required for public clients, S256 only
Refresh tokensLong-lived90 days, renewed on every refresh; each refresh replaces the old pair
guilds.join bodyaccess_token, nick, roles, mute, deafaccess_token only (set roles and nicknames afterwards with your bot)
guilds.join on a server with paused invitesAllowedRefused (invites_paused)
/users/@me/guilds paging (before, after, limit, with_counts)SupportedNot needed: everyone is in at most 100 servers, and all are returned
Error bodies on the API{code, message}{"error": {"code", "message"}} (token endpoints use the OAuth2 {error, error_description} like Discord)

Limits

WhatLimit
Token and revoke requests60 per minute per IP address, and 120 per minute per client
Bearer API calls120 per minute per access token
guilds.join30 per minute per app
Redirects per app10
Codes10 minutes, single use
Access tokens7 days
Refresh tokens90 days from the last refresh

Accounts that are suspended or scheduled for deletion can't authorize apps, and their tokens stop working; a deleted account's grants and tokens are gone. Bots can't authorize apps.

Keep it safe

  • Keep the client secret and every token on your server. Never put them in a page, an app you ship, a URL or a log. Public clients have no secret by design, and use PKCE instead.
  • Always send and check state, tied to the browser that started the sign-in.
  • Register the exact redirect URLs you use, all on https in production.
  • Ask for the fewest scopes you can, and revoke tokens when people log out of your site.