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://; plainhttp://only works forlocalhostand127.0.0.1while 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
- Your site sends the person to baff.gg's authorize link, with the scopes you need and a random
state. - 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.
- baff.gg sends them to your redirect URI with a one-time
codeand yourstate. - Your server checks
state, then trades thecodefor an access token and a refresh token at the token endpoint. - You call the API with
Authorization: Bearer <access token>, for exampleGET /api/users/@meto learn who they are.
1. The authorize link
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_type | Always code. |
client_id | Your application ID. |
redirect_uri | One of your registered redirects, exactly. You can leave it out if you registered only one. |
scope | Space-separated scopes (%20 or + in the URL). |
state | A 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. |
prompt | consent (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_challenge | PKCE: base64url SHA-256 of your verifier (43 characters). Required for public clients, optional otherwise. |
code_challenge_method | S256 (the only one supported) when you send code_challenge. |
permissions | With scope bot: the bot's permissions number, like on the Installation page. |
guild_id | With 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:
https://example.com/callback?code=6a1f...&state=RANDOM_STATEWhen they click Cancel, or the link was wrong:
https://example.com/callback?error=access_denied&error_description=The+person+cancelled.&state=RANDOM_STATEerror | Why |
|---|---|
access_denied | They clicked Cancel. |
invalid_scope | An unknown scope, no scope, or bot on an app without a bot. |
unsupported_response_type | response_type isn't code. |
invalid_request | A 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
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%2Fcallbackcurl -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/callbackThe 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.
{
"access_token": "bfxo_9c41...",
"token_type": "Bearer",
"expires_in": 604800,
"refresh_token": "bfxr_77d0...",
"scope": "identify email"
}access_tokenlasts 7 days (expires_inis in seconds).refresh_tokengets 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": "..."}.
| Status | error | Why |
|---|---|---|
| 400 | invalid_request | A missing parameter, or the secret sent twice. |
| 400 | invalid_grant | The code is unknown, used, expired, for another client or redirect; the PKCE verifier doesn't match; or the person deauthorized your app since. |
| 400 | unsupported_grant_type | Only authorization_code and refresh_token. |
| 401 | invalid_client | Unknown client, wrong secret, or no secret from a confidential client. |
| 429 | rate_limited | Slow down (see Limits). |
3. Use the access token
curl https://baff.gg/api/users/@me -H "Authorization: Bearer $ACCESS_TOKEN"{
"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:
| Route | Scope | Returns |
|---|---|---|
GET /api/users/@me | identify (email adds the address) | The user, above |
GET /api/users/@me/guilds | guilds | Every server they're in |
GET /api/users/@me/guilds/{serverId}/member | guilds.members.read | Their member info in one server |
GET /api/oauth2/@me | any | What this token is |
/api/users/@me/servers works the same as /api/users/@me/guilds.
[
{
"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.
{
"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).
| Status | code | Why |
|---|---|---|
| 401 | invalid_token | Unknown, expired or revoked, or the account can't be used right now (suspended or being deleted). |
| 401 | bearer_not_allowed | A Bearer token on a route that doesn't take one. |
| 403 | missing_scope | The 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.
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.
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.
- 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). - Make the challenge: base64url (no padding) of the SHA-256 of the verifier.
- Add
code_challenge=<challenge>&code_challenge_method=S256to the authorize link. - Send
code_verifier=<verifier>with the code to the token endpoint.
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
| Scope | Lets you | Consent screen says |
|---|---|---|
identify | GET /users/@me without the email | See your username, display name, avatar and banner |
email | Adds email and verified to /users/@me | See your email address |
guilds | GET /users/@me/guilds | See which servers you are in |
guilds.members.read | GET /users/@me/guilds/{id}/member | Read your member info in your servers |
guilds.join | Add them to servers your bot is in | Join servers for you |
bot | Adds your bot to a server they pick (they need Manage Server there) | Add a bot to a server you manage |
applications.commands | Nothing extra: your bot's slash commands already show up wherever it is | Use 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:
https://example.com/callback?code=...&state=...&guild_id=103700000000000123&permissions=3072permissions 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:
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, haveguilds.join, and be foruserId. - 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
201with the member when they joined,204when 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.
| Status | code | Why |
|---|---|---|
| 403 | bot_token_required | Not called with an application's bot token. |
| 403 | invalid_access_token | Unknown, expired or revoked, or from another app. |
| 403 | missing_scope | The access token has no guilds.join. |
| 403 | token_user_mismatch | The access token is for someone else. |
| 403 | missing_permission | Your bot doesn't have Create Invite there. |
| 404 | unknown_server | Your bot isn't in that server. |
// 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).
- In the portal's OAuth2 page, add the redirect
http://localhost:3000/callbackand Make Secret (or turn on Public Client). - Save this as
login-with-bafflux.mjs, then runCLIENT_ID=... CLIENT_SECRET=... node login-with-bafflux.mjsand open http://localhost:3000.
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.
<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:
| Discord | baff.gg | |
|---|---|---|
| Authorize page | discord.com/oauth2/authorize | baff.gg/oauth2/authorize |
| Token endpoints | discord.com/api/oauth2/token, /token/revoke | baff.gg/api/oauth2/token, /token/revoke |
| API base | discord.com/api/v10 | baff.gg/api (no version) |
avatar, banner, server icon | Image hashes you build CDN URLs from | Full image URLs |
discriminator | "0" | Always "0" |
tag on users | Not there | baff.gg's account label (official, staff, bot, verified_bot, or null) |
Server features | Discord's feature flags | baff.gg's server types (partnered, verified, official) |
connections, rpc, webhook.incoming, activities scopes | Exist | Not supported (invalid_scope) |
client_credentials grant | Supported for the app owner | Not supported (use your bot token) |
| Redirect schemes | Custom schemes allowed | https, or http on localhost/127.0.0.1 only |
| PKCE | Optional | Optional, required for public clients, S256 only |
| Refresh tokens | Long-lived | 90 days, renewed on every refresh; each refresh replaces the old pair |
guilds.join body | access_token, nick, roles, mute, deaf | access_token only (set roles and nicknames afterwards with your bot) |
guilds.join on a server with paused invites | Allowed | Refused (invites_paused) |
/users/@me/guilds paging (before, after, limit, with_counts) | Supported | Not 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
| What | Limit |
|---|---|
| Token and revoke requests | 60 per minute per IP address, and 120 per minute per client |
| Bearer API calls | 120 per minute per access token |
guilds.join | 30 per minute per app |
| Redirects per app | 10 |
| Codes | 10 minutes, single use |
| Access tokens | 7 days |
| Refresh tokens | 90 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.