Add "Login with Discord" to 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

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:

  1. Open it in the portal and go to OAuth2.
  2. Copy the Client ID and Client Secret (reset the secret to reveal it; it's shown once).
  3. 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:

  1. /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.
  2. You approve on Discord's page.
  3. Discord bounces you back to /callback?code=....
  4. The callback POSTs that code to Discord's token endpoint with your client ID and secret, gets back an access token, then calls /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

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 /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.
  • Standard documented flow (not container-verifiable): the successful token exchange and the /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 by dotenv.

Turning it into real login

Right now /callback just prints the username. To actually keep someone logged in, start a session:

  • Install 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 }.
  • Protect pages by checking if (!req.session.user) return res.redirect('/login') at the top of each route.
  • Add a /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.

Troubleshooting

  • 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.
  • Page won't load — the listen call must use SERVER_PORT and 0.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

Was this guide helpful?