Only show guides for your app
The Discord OAuth2 authorization-code flow on Falix — an Express app with a /login redirect and a /callback that exchanges the code for the user's identity, with secrets in .env.
"Login with Discord" lets people into your dashboard or tool without you ever handling a password — Discord vouches for who they are. This guide builds the whole flow on the Node.js application with Express: a /login link that sends users to Discord's consent screen, and a /callback that turns the returned code into the user's identity. Your app's ID and secret stay in .env.
| At a glance | |
|---|---|
| You need | A server running the Node.js application, and a Discord application (client ID + secret) |
| Build with | Express + Discord's OAuth2 authorization-code flow |
| Plan | Any — free runs while your session timer has time, premium runs 24/7 |
| Time | About thirty minutes |
The client ID and secret come from the Discord Developer Portal. Creating an application there is the same first step as making a bot — the test-server workflow guide walks through creating an application and a private server to try it in. Once you have an application:
http://YOUR_ADDRESS:PORT/callback. Discord rejects any redirect that doesn't match a registered one character-for-character.⚠️ Heads up: Your Falix address includes the port (find it on the Network page). Register that full URL as the redirect. When you later move behind a real domain with HTTPS, add that URL as a second redirect too.
Three files. First package.json:
{
"name": "discord-oauth-login",
"version": "1.0.0",
"private": true,
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.19.2"
}
}
Then .env, holding your secrets (never put these in your code):
DISCORD_CLIENT_ID=YOUR_CLIENT_ID_HERE
DISCORD_CLIENT_SECRET=YOUR_CLIENT_SECRET_HERE
OAUTH_REDIRECT_URI=http://YOUR_ADDRESS:PORT/callback
Then index.js — the whole flow:
require('dotenv').config();
const express = require('express');
const app = express();
const CLIENT_ID = process.env.DISCORD_CLIENT_ID;
const CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET;
const REDIRECT_URI = process.env.OAUTH_REDIRECT_URI;
const SCOPE = 'identify';
const API = 'https://discord.com/api/v10';
// Step 1: send the visitor to Discord's consent screen.
app.get('/login', (req, res) => {
const url = new URL(`${API}/oauth2/authorize`);
url.searchParams.set('client_id', CLIENT_ID);
url.searchParams.set('redirect_uri', REDIRECT_URI);
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', SCOPE);
res.redirect(url.toString());
});
// Step 2: Discord sends them back here with ?code=... (or ?error=...).
app.get('/callback', async (req, res) => {
if (req.query.error) return res.status(400).send(`Discord returned: ${req.query.error}`);
const code = req.query.code;
if (!code) return res.status(400).send('No code in callback.');
try {
// Exchange the code for an access token.
const tokenRes = await fetch(`${API}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
const token = await tokenRes.json();
if (!tokenRes.ok) {
console.error('token exchange failed:', token);
return res.status(401).send(`Login failed: ${token.error_description || token.error}`);
}
// Use the access token to read who just logged in.
const userRes = await fetch(`${API}/users/@me`, {
headers: { Authorization: `Bearer ${token.access_token}` },
});
const user = await userRes.json();
// In a real app you'd start a session here (see the note below).
res.send(`Logged in as ${user.username} (${user.id})`);
} catch (err) {
console.error('oauth error:', err.message);
res.status(500).send('Something went wrong talking to Discord.');
}
});
app.get('/', (req, res) => res.send('<a href="/login">Login with Discord</a>'));
const PORT = process.env.SERVER_PORT || 8080;
app.listen(PORT, '0.0.0.0', () => console.log(`Listening on port ${PORT}`));
Press Start. Falix runs npm install, then prints Listening on port … — your online signal. Now open your server's address and click Login with Discord:
/login builds Discord's authorize URL and redirects you to it. The URL carries your client_id, the redirect_uri, response_type=code, and scope=identify — everything Discord needs to show the consent screen./callback?code=..../users/@me to read your username and ID.The three moving parts:
| Piece | Job |
|---|---|
/login redirect |
Constructs the authorize URL and sends the user to Discord |
Token exchange (/oauth2/token) |
Trades the one-time code for an access token — this is where the secret is used |
/users/@me |
Uses the token to fetch the logged-in user's profile |
This build was checked on the Node runtime Falix uses, and it's worth being precise about what that proved:
/login route constructs the correct authorize URL (client_id, url-encoded redirect_uri, response_type=code, scope=identify); and /callback genuinely reaches Discord's real token endpoint and handles its error responses — a bad or expired code comes back as a documented OAuth error (like invalid_grant / invalid_client) and the handler returns a clean 401 instead of crashing./users/@me read only happen after a real person clicks "Authorize" on Discord's page, which can't be scripted in a test. Those two calls are written straight from Discord's official OAuth2 documentation and use the exact request shapes above.🎯 Good to know: The token exchange happens server-to-server — your secret never touches the browser. That's the whole point of the authorization-code flow, and why the secret lives in
.env, read at startup bydotenv.
Right now /callback just prints the username. To actually keep someone logged in, start a session:
express-session from the Packages page, add app.use(session({ ... })), and after a successful exchange set req.session.user = { id: user.id, username: user.username }.if (!req.session.user) return res.redirect('/login') at the top of each route./logout route that calls req.session.destroy(...).For the bigger picture on sessions, cookies, and the honest security minimum, see Sessions, JWT, and the honest minimum. Everything about OAuth beyond these Falix-shaped parts — refresh tokens, extra scopes like guilds, and the exact field list — lives in Discord's own docs at discord.com/developers/docs.
invalid_redirect_uri or Discord shows an error page — the OAUTH_REDIRECT_URI in .env doesn't match a redirect registered in the portal. They must be identical, including http/https, the port, and the path.Login failed: invalid_client — the client ID or secret in .env is wrong. Copy them again from the OAuth2 page (reset the secret if unsure).Login failed: invalid_grant — the code was already used or expired. Codes are single-use and short-lived; start the login again.listen call must use SERVER_PORT and 0.0.0.0. See I can't reach my app..env. See Keep secrets out of Git and Environment variables & secrets.