An Express + Node backend that talks to Postgres via Sequelize and exposes a
JSON API for your React app. Ships with one example resource (Task) showing
the full CRUD pattern, and a complete auth setup with two ways to log in —
email + password that we handle ourselves, and social login via Auth0.
Copy the example, then delete it.
This is one half of a pair. The React app that talks to it lives here:
→ ttp-frontend-setup (auth0 branch)
Both need to be running for login to work, and a few settings have to match across the two — they're called out where they come up below.
npm install
createdb capstone_dev # once — must match LOCAL_DATABASE_NAME in db/index.js
cp .env.example .env # then fill in the values below
npm run seed # drop + recreate tables, insert sample data
npm run dev # start with auto-restart (npm start for no restart)You should see:
🐘 Database connection established.
🧩 Models synced.
🚀 Server is running on PORT: 8080
Quick check: http://localhost:8080/check · the API: http://localhost:8080/api/tasks
The server refuses to start without these — that's on purpose, so you find out now rather than after a login silently does the wrong thing.
| Variable | What it is |
|---|---|
JWT_SECRET |
Signs the tokens we issue. Generate with the command below. |
AUTH0_DOMAIN |
Your tenant domain, no https:// (e.g. dev-abc.us.auth0.com). |
AUTH0_AUDIENCE |
Your API's Identifier in the Auth0 dashboard. Must match the frontend's VITE_AUTH0_AUDIENCE exactly. |
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"FRONTEND_URL, PORT, JWT_EXPIRES_IN, and AUTH0_CLAIMS_NAMESPACE all have
sensible defaults for local development. See .env.example for the rest.
app.js entry point: middleware, routes, server start
db/index.js Postgres connection (Sequelize)
db/seed.js sample data (npm run seed)
models/ model definitions + associations (models/index.js)
middleware/auth.js requireAuth — accepts either credential
routes/ resource routers, incl. auth.routes.js (routes/index.js)
public/ static info page served at /
There are two doors into this app, and they meet in the same place.
Door 1 — email + password (ours). POST /auth/signup hashes the password
with bcrypt and stores only the hash; we never store the password itself.
POST /auth/login hashes the attempt and compares. On success we sign a JWT and
put it in an httpOnly cookie, which JavaScript cannot read — so an XSS bug
can't steal it. The browser attaches that cookie to every later request by
itself, which is why the frontend never touches a token.
Door 2 — Auth0 (social login). Auth0 collects the credential and hands the
frontend an access token, sent as Authorization: Bearer <token>. We can't
verify it with a shared secret: Auth0 signs it with a private key (RS256) and
publishes the matching public one. express-oauth2-jwt-bearer fetches that key
and checks the signature for us.
requireAuth accepts either and resolves both to the same thing: req.user,
a row from our own users table. Your route handlers never have to care which
door someone came through.
app.get('/api/thing', requireAuth, handler) // one route
router.use(requireAuth) // every route in a router| Method | Path | Needs | Does |
|---|---|---|---|
| POST | /auth/signup |
— | create an account, set the cookie |
| POST | /auth/login |
— | check the password, set the cookie |
| POST | /auth/logout |
— | clear the cookie |
| POST | /auth/auth0 |
Auth0 token | after a social login, create the user if new |
| GET | /auth/me |
either | the logged-in user's row |
| GET | /api/protected |
either | a throwaway route for confirming auth works |
Logging out has to be a request to the server — an httpOnly cookie is exactly the kind JavaScript can't delete. A JWT is stateless, so there's no session to destroy; "logging out" just means throwing the cookie away.
One table holds both kinds of user, which is why both passwordHash and
auth0Id are nullable — each row fills in one or the other:
- password signup →
passwordHashset,auth0Idnull - Auth0 login →
auth0Idset (the token'ssub),passwordHashnull
We key Auth0 users on auth0Id, never on email: emails change, the sub never
does. User.prototype.toJSON strips passwordHash, so it can't leak out of an
endpoint even if you forget to remove it.
Auth0 access tokens don't carry email or name unless you ask. Without this,
login still works — those two columns are just null. To get them, add a
Post-Login Action in the Auth0 dashboard (Actions → Library → Build Custom):
exports.onExecutePostLogin = async (event, api) => {
const namespace = 'https://myapp.example.com'; // = AUTH0_CLAIMS_NAMESPACE
api.accessToken.setCustomClaim(`${namespace}/email`, event.user.email);
api.accessToken.setCustomClaim(`${namespace}/name`, event.user.name);
};The namespace is required — Auth0 silently drops custom claims that aren't
namespaced — and it must match AUTH0_CLAIMS_NAMESPACE in your .env
character for character. Remember to drag the Action into the Login flow.
| Method | Path | Does |
|---|---|---|
| GET | /api/tasks |
list all tasks |
| GET | /api/tasks/:id |
get one task |
| POST | /api/tasks |
create a task |
| PUT | /api/tasks/:id |
replace a task (all fields) |
| PATCH | /api/tasks/:id |
update part of a task |
| DELETE | /api/tasks/:id |
delete a task |
These are public on purpose, so you can play with them before auth exists.
To make tasks private per user, add the guard where the router is mounted in
app.js and uncomment the associations in models/index.js:
app.use('/api/tasks', requireAuth, taskRouter);- Model — copy
models/task.model.js, change the fields, export it frommodels/index.js. - Router — copy
routes/task.routes.js, swapTaskfor your model, export it fromroutes/index.js. - Mount it — in
app.js:app.use('/api/posts', postRouter). - Associations — define relationships in
models/index.js.
- Set
DATABASE_URL(your host provides it) —db/index.jspicks it up and enables SSL. - Set
FRONTEND_URLto your deployed React URL so CORS allows it. - Set
NODE_ENV=production. This tightens the rate limit and, importantly, switches the login cookie toSecure+SameSite=None— required once the frontend and API are on different domains, or the cookie is never sent. - Set a different
JWT_SECRETthan the one on your laptop. - Set
PORTif your host requires a specific one (defaults to 8080).
| Symptom | Fix |
|---|---|
Missing JWT_SECRET on startup |
You skipped cp .env.example .env, or left JWT_SECRET blank. |
ECONNREFUSED ... 5432 |
Postgres isn't running, or the db doesn't exist. Start Postgres, run createdb capstone_dev. |
database "capstone_dev" does not exist |
createdb capstone_dev (or change the name in db/index.js — and create that one). |
| Login works, then every request 401s | Two suspects: AUTH0_AUDIENCE doesn't match the frontend's VITE_AUTH0_AUDIENCE, or the frontend forgot credentials: 'include'. |
| Model changes don't appear | app.js only creates missing tables. Run npm run seed to rebuild them. |
429 Too many requests while developing |
You're running with NODE_ENV=production locally. Unset it. |
port 8080 already in use |
Stop the other server, or set PORT in .env. |