"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 |
Step 1 — Create a Discord application
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:
- Open it in the portal and go to OAuth2.
- Copy the Client ID and Client Secret (reset the secret to reveal it; it's shown once).
- Under Redirects, add your callback URL exactly as your app will use it — for example
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.
Step 2 — Create the project files
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}`));
Step 3 — Start it and walk the flow
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:
/loginbuilds Discord's authorize URL and redirects you to it. The URL carries yourclient_id, theredirect_uri,response_type=code, andscope=identify— everything Discord needs to show the consent screen.- You approve on Discord's page.
- Discord bounces you back to
/callback?code=.... - The callback POSTs that code to Discord's token endpoint with your client ID and secret, gets back an access token, then calls
/users/@meto 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 |
What's verified, and what's standard flow
This build was checked on the Node runtime Falix uses, and it's worth being precise about what that proved:
- Verified: the app boots and listens; the
/loginroute constructs the correct authorize URL (client_id, url-encodedredirect_uri,response_type=code,scope=identify); and/callbackgenuinely reaches Discord's real token endpoint and handles its error responses — a bad or expired code comes back as a documented OAuth error (likeinvalid_grant/invalid_client) and the handler returns a clean 401 instead of crashing. - Standard documented flow (not container-verifiable): the successful token exchange and the
/users/@meread 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.
Turning it into real login
Right now /callback just prints the username. To actually keep someone logged in, start a session:
- Install
express-sessionfrom the Packages page, addapp.use(session({ ... })), and after a successful exchange setreq.session.user = { id: user.id, username: user.username }. - Protect pages by checking
if (!req.session.user) return res.redirect('/login')at the top of each route. - Add a
/logoutroute that callsreq.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.
Troubleshooting
invalid_redirect_urior Discord shows an error page — theOAUTH_REDIRECT_URIin.envdoesn't match a redirect registered in the portal. They must be identical, includinghttp/https, the port, and the path.Login failed: invalid_client— the client ID or secret in.envis 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.- Page won't load — the
listencall must useSERVER_PORTand0.0.0.0. See I can't reach my app. - Secrets leaked to Git — never commit
.env. See Keep secrets out of Git and Environment variables & secrets.
Next steps
- Sessions, JWT, and the honest minimum
- Create a private test server for your app
- Environment variables & secrets
- Discord's full OAuth2 reference is at discord.com/developers/docs.