User Authentication API

Build an Express API with Neon users, password hashing, and JWT authentication.

Introduction

30 Second Summary

A sign-in form looks simple from the outside. Behind it, a service must recognize the right person while keeping every password out of sight.

In this project, you will build a local REST API that carries a user from registration to a protected profile. You will use Postman to prove valid credentials work while invalid credentials and tokens are rejected.

What You'll Build

You'll demo an Express.js server that registers an account, returns a signed JWT after login, and reveals the user's profile only when the request carries a valid token.

By the end of this project, you'll have:

  • A working local API that returns { "status": "ok" } when you send its health request.
  • A secure account flow that stores a salted Argon2id password hash in Neon Postgres.
  • A reusable Postman collection that proves registration, login, and protected profile requests accept valid input while rejecting invalid access.
  • Secret Mission: Change one character in a valid JWT and prove that signature verification rejects it.

Are there any prerequisites?

None. The project includes Windows setup for every required tool.

Beginner-level JavaScript and API experience is enough to follow along.

Before We Start

Before you touch the tools, commit to an Express authentication API with health, registration, login, JWT bearer authentication, and an authenticated profile endpoint. Plaintext passwords would turn a database leak into direct exposure of every user's reusable password.

Set Up the Project and Test Health

A working authentication API depends on a reliable request-response cycle. A small health endpoint isolates that cycle before database or security code adds more moving parts.

In this step, you'll verify Node.js, npm, Visual Studio Code, Postman, and Windows PowerShell. You'll then run an Express.js server that returns a visible health response.

In this step, get ready to:
  • Verify the Windows tools required to build and test the API.
  • Create the project files with pinned npm dependencies.
  • Send a health request from Postman and pass a status assertion.
Verify your Windows tools

Each tool owns one part of your local workflow. Verifying them now keeps installation problems separate from API problems.

How do the tools fit together?

  • Node.js runs the JavaScript server on your computer.
  • npm installs the packages listed in package.json.
  • Express.js matches incoming requests to route handlers.
  • Visual Studio Code gives you one place to edit the project files.
  • Postman sends requests and checks the responses.
  • Press the Windows key to open Windows search.
  • Type PowerShell and press Enter.
  • Check your Node.js and npm versions by running these commands:
node -v
npm -v

What do these checks prove?

The first command prints the installed Node.js version. The second command confirms that npm is available through the same PowerShell session.

✔️ I see version 24.22.1

Node.js v24.22.1 LTS is ready. Seeing a version from the second command also confirms that npm is ready.

That is the first setup check complete. Your JavaScript runtime can now use the exact version expected by this project.

ⓧ I see another version

This project pins Node.js to v24.22.1 LTS. Installing that release keeps its built-in security APIs aligned with the project code.

  • Open the official Node.js release page.
  • Download the Windows installer for v24.22.1 LTS that matches your processor.
  • Run the downloaded installer.
  • Keep the default installation options.
  • Close every PowerShell window after the installation finishes.
  • Reopen PowerShell through Windows search.
  • Repeat both version checks from above.

Still seeing the earlier version?

Windows may still be using a path from the earlier installation. Restart Windows before repeating the checks.

Ask for help: Help me find why PowerShell still uses my earlier Node.js version.

ⓧ Command not found

PowerShell cannot reach Node.js yet. The official Windows installer adds the runtime and npm to the command path.

  • Open the official Node.js release page.
  • Download the Windows installer for v24.22.1 LTS that matches your processor.
  • Run the downloaded installer.
  • Keep the default installation options.
  • Close every PowerShell window after the installation finishes.
  • Reopen PowerShell through Windows search.
  • Repeat both version checks from above.

Still missing the command?

Restart Windows so it reloads the updated command path. Repeat the checks in a new PowerShell window.

Ask for help: Help me make Node.js available in Windows PowerShell.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code and check whether the app appears.

✔️ Visual Studio Code opens

  • Select Visual Studio Code from the search results.

Keep Visual Studio Code open. You'll use it after the final tool check.

ⓧ Visual Studio Code is missing

The editor is ready when its welcome window opens.

  • Press the Windows key to open Windows search.
  • Type Postman and check whether the desktop app appears.

✔️ Postman opens

  • Select Postman from the search results.

Keep Postman available. You'll create the health request after the server starts.

ⓧ Postman is missing

  • Open the official Postman download page.
  • Select Download for Windows x64 for an Intel or AMD computer.
  • Select Download for Windows ARM64 for an ARM computer.
  • Run the downloaded .exe file.
  • Press the Windows key after the installation finishes.
  • Type Postman and press Enter.

The API client is ready when its workbench opens.

Having trouble with a Windows tool?

Close the affected app before running its installer again. Restart Windows if search still cannot find the installed app.

Ask for help: Help me troubleshoot my Windows development tool setup.

Build the health-only Express server

The project uses ES modules so each file can import or export code with JavaScript module syntax. The health route returns a small JSON object whenever a client sends the matching request.

How does the health route work?

A client sends a request to the server. Express.js compares the method and path with the routes registered in src/app.js.

The matching route sends a JSON response back to the client. That round trip proves the server can receive and answer requests.

  • Switch back to the PowerShell window from your version checks.
  • Move to your Desktop by running this command:
cd ~/Desktop

What did this command do?

PowerShell now uses your Desktop as the location for the next command. This keeps the project folder easy to find.

  • Create the user-authentication-api folder by running this command:
mkdir user-authentication-api

What changed?

PowerShell creates the user-authentication-api folder on your Desktop. The command output identifies the new directory.

  • Enter the new folder and open it in Visual Studio Code by running these commands:
cd user-authentication-api
code .

What did these commands do?

The first command changes PowerShell's current location to user-authentication-api. The second command opens that exact folder in Visual Studio Code.

  • Create package.json in the Visual Studio Code Explorer sidebar with this content:
{
  "name": "user-authentication-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": "24.22.1"
  },
  "scripts": {
    "start": "node src/server.js",
    "generate:secret": "node scripts/generate-secret.js",
    "db:push": "drizzle-kit push"
  },
  "dependencies": {
    "dotenv": "18.0.9",
    "drizzle-orm": "0.45.4",
    "express": "5.3.0",
    "jose": "6.2.13",
    "pg": "8.23.1"
  },
  "devDependencies": {
    "drizzle-kit": "0.31.11"
  }
}

What does this configuration do?

  • The type value enables ES module syntax throughout the project.
  • The engines entry records the pinned Node.js version.
  • The start script runs src/server.js.
  • The dependency entries lock every package to the version used by the completed API.
  • Save package.json.
  • Switch back to the PowerShell window inside user-authentication-api.
  • Install the pinned dependencies by running:
npm install

What does npm install do?

npm reads package.json and downloads its dependencies into a local node_modules folder. It also records the resolved dependency tree for repeatable installs.

The first install may sit quietly while packages download. Give it a minute before treating the pause as a problem.

When the command finishes, you'll see an installation summary in PowerShell. Visual Studio Code also lists node_modules in the Explorer sidebar.

Did the dependency install fail?

  • Confirm PowerShell is inside the user-authentication-api folder.
  • Confirm package.json is saved without red syntax markers in Visual Studio Code.
  • Check your internet connection before repeating the install.

Ask for help: Help me debug my npm dependency installation.

  • Create .gitignore in the Visual Studio Code Explorer sidebar with this content:
node_modules/
.env

Why ignore these paths?

The node_modules/ folder can be rebuilt from package.json. The .env file later holds local configuration that must stay outside source control.

  • Save .gitignore.

You should now see .gitignore, node_modules, and package.json in the Explorer sidebar.

  • Select New Folder in the Visual Studio Code Explorer sidebar.
  • Enter src as the folder name.
  • Create src/app.js inside the new folder.

Try assembling the Express app yourself before checking the finished file. Complete the object passed to res.json() so the health route reports the planned status.

✔️ I'll try it first

  • Import Express into src/app.js.
  • Create the app instance.
  • Add the JSON middleware with the 10kb limit.
  • Add the GET health route.
  • Complete the healthy JSON response object.
  • Export app as the default export.

ⓧ Let me check my work

  • Compare your src/app.js file with this completed version:
import express from 'express';

const app = express();

app.use(express.json({ limit: '10kb' }));

app.get('/health', (req, res) => {
  return res.json({ status: 'ok' });
});

export default app;

What does this code do?

  • The Express import provides the function used to create the application.
  • The JSON middleware prepares the app to read small JSON request bodies in later routes.
  • The health handler returns the fixed response that proves the route ran.
  • The default export lets src/server.js start the same app.
  • Save src/app.js.

The Explorer sidebar should now show app.js inside src.

  • Create src/server.js with this content:
import 'dotenv/config';
import app from './app.js';

const port = Number(process.env.PORT || 3000);

app.listen(port, (error) => {
  if (error) {
    throw error;
  }

  console.log(`User Authentication API listening at http://localhost:${port}`);
});

What does this code do?

  • The first import loads local environment values before the server reads them.
  • The second import brings in the Express app from src/app.js.
  • The port value uses local configuration when available.
  • The listener starts the app and prints the local address.
  • Save src/server.js.

The next command stays active because the server waits for requests. Press Ctrl+C whenever you need to stop it.

Before you run it, which local address do you expect the server to print?

  • Start the local server by running:
npm start

What should you see?

PowerShell should print User Authentication API listening at http://localhost:3000. The process remains active so Postman can reach the API.

Did the server stop or show an error?

  • Confirm both JavaScript files are saved inside src.
  • Compare the import paths with the completed code below.
  • Confirm the dependency installation completed before starting the server.

Ask for help: Help me debug why my Express health server does not start.

✔️ Awesome, I've got everything!

Great. Double check that all four files are saved before testing the endpoint.

ⓧ I'd like to double check the full code

Compare each project file with these exact versions.

node_modules/
.env
{
  "name": "user-authentication-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": "24.22.1"
  },
  "scripts": {
    "start": "node src/server.js",
    "generate:secret": "node scripts/generate-secret.js",
    "db:push": "drizzle-kit push"
  },
  "dependencies": {
    "dotenv": "18.0.9",
    "drizzle-orm": "0.45.4",
    "express": "5.3.0",
    "jose": "6.2.13",
    "pg": "8.23.1"
  },
  "devDependencies": {
    "drizzle-kit": "0.31.11"
  }
}
import express from 'express';

const app = express();

app.use(express.json({ limit: '10kb' }));

app.get('/health', (req, res) => {
  return res.json({ status: 'ok' });
});

export default app;
import 'dotenv/config';
import app from './app.js';

const port = Number(process.env.PORT || 3000);

app.listen(port, (error) => {
  if (error) {
    throw error;
  }

  console.log(`User Authentication API listening at http://localhost:${port}`);
});
Test the health request in Postman

The server is now waiting for an HTTP request. Postman lets you save that request with an assertion that checks the returned status automatically.

Why use a collection variable?

A collection variable stores one value for every request in the collection. Using baseUrl keeps the local server address in one place.

Future requests can reuse the same variable. A single update can point the entire collection at a different server.

Postman may ask you to sign in before it saves a collection. Your Postman credentials stay with Postman and never enter this API.

  • Return to Postman from the earlier tool check.
  • Complete the Postman sign-in prompt if it appears.
  • Select Collections in the sidebar.
  • Select the create control beside the sidebar search box.
  • Select Collection.
  • Name the collection User Authentication API.

What does the collection hold?

The collection groups requests that belong to this API. It also stores reusable variables and test scripts.

  • Select the User Authentication API collection.
  • Select the Variables tab.
  • Enter baseUrl in the Add variable box.
  • Enter http://localhost:3000 as its value.

Postman saves collection variable changes automatically. The baseUrl variable is now available to every request in this collection.

  • Open the User Authentication API collection in the sidebar.
  • Select Add Request.
  • Select GET as the request method.
  • Enter {{baseUrl}}/health as the request URL.
  • Select Scripts.
  • Select Post-response.
  • Add the status assertion by pasting this script:
pm.test("Status code is 200", function () {
  pm.response.to.have.status(200);
});

What does this test do?

The test runs after Postman receives a response. It passes only when the response status is 200.

A saved assertion turns the health request into a reusable check. Future collection runs can report the result automatically.

  • Name the request Health.
  • Save the request inside User Authentication API.

Before you send the request, what status and response body do you expect from the route?

  • Send the health request by selecting Send.

You'll see status 200. The response body shows {"status":"ok"}.

  • Select Test Results in the response area.

You'll see the Status code is 200 test pass. Your runtime, dependencies, server, route, and API client now work as one request-response flow.

You have the first visible proof of the project working. The API can receive a real request and return checked JSON.

Didn't get the health response?

  • Confirm the PowerShell window running the server is still active.
  • Confirm baseUrl contains http://localhost:3000.
  • Confirm the request uses GET with {{baseUrl}}/health.

Ask for help: Help me debug my Postman health request.

Your health-only API is running and covered by a saved Postman test. Next, you'll connect it to a persistent Neon database and model the users table.

Create the Neon Database

Your health route proves that Express can answer a request. User accounts now need storage that survives server restarts.

A Neon PostgreSQL database gives those accounts a persistent home. Drizzle ORM lets a JavaScript schema define the table before it is created.

In this step, get ready to:
  • Set up a Neon Free project with local pooled and direct connection settings.
  • Define the Drizzle configuration and users table schema.
  • Create the database client and prove the health endpoint still works.
Create the Neon project and local environment

Your application uses a connection pool for repeated runtime queries. Drizzle Kit uses a direct connection when it applies schema changes.

  • In the Explorer sidebar in Visual Studio Code, create .env.example inside the user-authentication-api folder.
  • Add the environment variable template by pasting this content:
PORT=3000
DATABASE_URL=your-neon-pooled-connection-string
DATABASE_URL_UNPOOLED=your-neon-direct-connection-string
JWT_SECRET=replace-with-64-hex-characters

What does this file do?

  • PORT records the local server port.
  • DATABASE_URL represents the pooled runtime connection.
  • DATABASE_URL_UNPOOLED represents the direct schema connection.
  • JWT_SECRET reserves a place for a future JWT signing secret.
  • Save .env.example.
  • Confirm .env.example appears in the Explorer sidebar.

Can't see the environment template?

Check that .env.example sits beside package.json. A file created inside src is in the wrong location.

Ask for help: Help me check where .env.example was created in my user-authentication-api project.

  • Create a scripts folder inside user-authentication-api from the Explorer sidebar.
  • Create generate-secret.js inside the new scripts folder.
  • Add the secret generation helper by pasting this code:
import { randomBytes } from 'node:crypto';

console.log(randomBytes(32).toString('hex'));

What does this helper do?

randomBytes(32) creates random data for a future signing secret. Hexadecimal encoding turns those bytes into a 64-character value.

The helper prints the value when its package script runs. It keeps credential generation out of your application code.

  • Save scripts/generate-secret.js.
  • Confirm the scripts folder now contains generate-secret.js in the Explorer sidebar.
  • Create .env beside .env.example from the Explorer sidebar.
  • Copy all four lines from .env.example into .env.

Missing the scripts folder?

Expand the user-authentication-api folder in the Explorer. The path should read scripts/generate-secret.js.

Ask for help: Help me place generate-secret.js inside the scripts folder.

  • Visit the Neon Console in your browser.
  • Complete Neon's sign-in flow.
  • Select New Project on the Projects page.
  • Enter user-authentication-api as the project name.
  • Confirm the Free plan is selected.
  • Keep the default region.
  • Select Create project.

That is the cloud side ready. Your API now has a persistent database home.

This part handles a live database password, so it is worth slowing down for one careful check. Your existing .gitignore already excludes .env from source control.

  • Select Connect in the Neon Console.
  • Keep the selected branch.
  • Keep the selected database.
  • Switch Connection pooling on.
  • Copy the generated connection string.
  • Paste the copied value over your-neon-pooled-connection-string in .env.

Keep the URLs Separate

The pooled URL belongs after DATABASE_URL=. Pasting it immediately prevents the next copied value from replacing it on your clipboard.

  • Return to the open Connect window.
  • Switch Connection pooling off.
  • Copy the generated direct connection string.
  • Paste the copied value over your-neon-direct-connection-string in .env.
  • Save .env.
  • Confirm .gitignore still contains the .env line.

Why Two Connection Strings?

Your running API uses DATABASE_URL so several requests can share managed database connections. This suits a long-running local server.

Drizzle Kit uses DATABASE_URL_UNPOOLED when it changes the schema. The direct connection keeps schema operations outside the pool.

Why node-postgres for This API?

Neon's HTTP driver fits request-scoped serverless code. Your local Express process stays running between requests.

The node-postgres Pool reuses connections across that process. Drizzle accesses the pool through the installed pg package.

Can't find both connection strings?

Open Connect again. Turn Connection pooling on for the pooled value.

Turn the same control off for the direct value. Keep both values inside .env.

Ask for help: Help me distinguish the pooled and direct Neon connection strings.

Define and push the users schema

A relational table stores each user as a row with predictable columns. Database constraints block incomplete or duplicate records before they spread through the API.

  • Create a db folder inside the existing src folder from the Explorer sidebar.
  • Create schema.js inside src/db.
  • Define the users table by pasting this code:
import { pgTable, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: uuid('id').defaultRandom().primaryKey(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  passwordHash: varchar('password_hash', { length: 255 }).notNull(),
  createdAt: timestamp('created_at', { withTimezone: true })
    .defaultNow()
    .notNull(),
});

What does this schema do?

  • id uses a random UUID as the primary key for each user.
  • email accepts up to 255 characters. Its unique constraint prevents two rows from using the same email.
  • passwordHash maps to the password_hash database column.
  • createdAt maps to created_at and records the insertion time.
  • Save src/db/schema.js.
  • Confirm schema.js appears inside src/db in the Explorer sidebar.

Seeing a schema syntax error?

Check that createdAt ends with .defaultNow().notNull() across the chained lines. Confirm the final table definition closes with });.

Ask for help: Help me compare my users schema with the expected Drizzle structure.

  • Create drizzle.config.js beside package.json from the Explorer sidebar.
  • Configure Drizzle Kit by pasting this code:
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';

if (!process.env.DATABASE_URL_UNPOOLED) {
  throw new Error('DATABASE_URL_UNPOOLED is not set in the .env file');
}

export default defineConfig({
  schema: './src/db/schema.js',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL_UNPOOLED,
  },
});

What does this configuration do?

  • dotenv/config loads the local environment values before the configuration reads them.
  • The missing-variable check stops the push when DATABASE_URL_UNPOOLED is unavailable.
  • schema points Drizzle Kit to src/db/schema.js as the source of truth.
  • dbCredentials.url supplies the direct Neon connection for schema changes.
  • Save drizzle.config.js.
  • Select Terminal from the Visual Studio Code top menu.
  • Select New Terminal.
  • Confirm the PowerShell prompt is inside user-authentication-api.

Before you push the schema, which four columns do you expect Drizzle Kit to create from users?

  • Apply the schema to Neon by running this command:
npx drizzle-kit push

What does this command do?

The command reads drizzle.config.js and compares the JavaScript schema with the current Neon database. It applies the required table changes through the direct connection.

You should see Drizzle Kit complete the schema push without an error. The users table now exists in Neon.

Did the schema push fail?

  • Check that DATABASE_URL_UNPOOLED contains the direct connection string in .env.
  • Confirm drizzle.config.js sits beside package.json.
  • Confirm src/db/schema.js matches the schema above.

Ask for help: Help me debug my Drizzle schema push without revealing my database URL.

Connect the runtime and verify the result

The schema defines the database shape. The runtime client gives future API routes a shared path to that database.

  • Create index.js inside the existing src/db folder.
  • Create the pooled database client by pasting this code:
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';

if (!process.env.DATABASE_URL) {
  throw new Error('DATABASE_URL is not set in the .env file');
}

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

export const db = drizzle({ client: pool });

What does this database client do?

  • The environment import loads DATABASE_URL before the client reads it.
  • The missing-variable check stops database code when the pooled connection is unavailable.
  • Pool manages reusable PostgreSQL connections for the running API.
  • db exposes the pool through Drizzle for future queries.
  • Save src/db/index.js.
  • Confirm index.js appears beside schema.js in src/db.

Before you inspect the table, which schema details should appear as columns and constraints?

  • Return to the Neon Console.
  • Select Tables in the sidebar.
  • Select the users table.

You should see id, email, password_hash, and created_at. The id column is the UUID primary key.

Before you resend the health request, do you expect the new database files to change its response?

  • Switch back to the Postman desktop app from earlier.
  • Select the saved health request in the User Authentication API collection.
  • Click Send.

You should still see a 200 response containing { "status": "ok" }. Your database layer now exists without breaking the original API response.

Missing the table or health response?

If users is missing in Neon, confirm the Console is showing the same project referenced by DATABASE_URL_UNPOOLED.

If the health request fails, confirm the original server process is still running at http://localhost:3000.

Ask for help: Help me identify whether my Neon table or local health endpoint is failing.

  • Compare your saved project files with the complete reference below.

✔️ Awesome, I've got everything!

Great. Your saved files now match the database foundation for this project.

ⓧ I'd like to double check the full code

Compare each file with this cumulative project reference. Keep your live connection strings inside .env.

node_modules/
.env
PORT=3000
DATABASE_URL=your-neon-pooled-connection-string
DATABASE_URL_UNPOOLED=your-neon-direct-connection-string
JWT_SECRET=replace-with-64-hex-characters
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';

if (!process.env.DATABASE_URL_UNPOOLED) {
  throw new Error('DATABASE_URL_UNPOOLED is not set in the .env file');
}

export default defineConfig({
  schema: './src/db/schema.js',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL_UNPOOLED,
  },
});
{
  "name": "user-authentication-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": "24.22.1"
  },
  "scripts": {
    "start": "node src/server.js",
    "generate:secret": "node scripts/generate-secret.js",
    "db:push": "drizzle-kit push"
  },
  "dependencies": {
    "dotenv": "18.0.9",
    "drizzle-orm": "0.45.4",
    "express": "5.3.0",
    "jose": "6.2.13",
    "pg": "8.23.1"
  },
  "devDependencies": {
    "drizzle-kit": "0.31.11"
  }
}
import { randomBytes } from 'node:crypto';

console.log(randomBytes(32).toString('hex'));
import express from 'express';

const app = express();

app.use(express.json({ limit: '10kb' }));

app.get('/health', (req, res) => {
  return res.json({ status: 'ok' });
});

export default app;
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';

if (!process.env.DATABASE_URL) {
  throw new Error('DATABASE_URL is not set in the .env file');
}

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

export const db = drizzle({ client: pool });
import { pgTable, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: uuid('id').defaultRandom().primaryKey(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  passwordHash: varchar('password_hash', { length: 255 }).notNull(),
  createdAt: timestamp('created_at', { withTimezone: true })
    .defaultNow()
    .notNull(),
});
import 'dotenv/config';
import app from './app.js';

const port = Number(process.env.PORT || 3000);

app.listen(port, (error) => {
  if (error) {
    throw error;
  }

  console.log(`User Authentication API listening at http://localhost:${port}`);
});

Your API now has a persistent users table and a reusable database client. Next up, you will register users without storing their passwords.

Register Users Securely

Your Neon users table can persist accounts. Registration now creates a new risk because data enters your API from an external request.

This step validates each email and password before storage. Node.js Argon2id transforms each password with a fresh salt before Drizzle ORM inserts the user.

In this step, get ready to:
  • Build a password service that creates salted Argon2id hashes.
  • Add a registration route that validates input before inserting a user.
  • Prove valid requests succeed while malformed or duplicate requests fail.
Build the password hashing service

A password hash is a one-way result created from the password plus a random salt. The salt gives identical passwords different stored values.

Why use Argon2id?

Argon2id deliberately consumes memory plus processing time. This makes large password-guessing attacks more expensive.

The service uses m=19456, t=2, and p=1. Each hash also receives a random 16-byte salt.

  • Switch back to the user-authentication-api workspace in Visual Studio Code.
  • Select the New File icon in the Explorer sidebar.
  • Enter src/services/password.service.js as the file path.
  • Confirm the file creation.

You should see password.service.js inside a new src/services folder.

  • Add the Argon2id configuration plus the key-derivation helper by pasting this first section into password.service.js:
import { argon2, randomBytes, timingSafeEqual } from 'node:crypto';
import { Buffer } from 'node:buffer';
import { promisify } from 'node:util';

const argon2Async = promisify(argon2);
const MEMORY_COST = 19456;
const PASSES = 2;
const PARALLELISM = 1;
const TAG_LENGTH = 32;

async function derivePasswordKey(password, salt) {
  return argon2Async('argon2id', { message: password, nonce: salt, parallelism: PARALLELISM, tagLength: TAG_LENGTH, memory: MEMORY_COST, passes: PASSES });
}

What does this code do?

  • The constants hold the Argon2id cost settings used for every password.
  • The promisify() call converts Node.js Argon2 into a Promise-based function.
  • The derivePasswordKey() helper combines a password with its salt to produce a 32-byte result.
  • Add the hash creation function directly below derivePasswordKey() by pasting:
export async function hashPassword(password) {
  const salt = randomBytes(16);
  const hash = await derivePasswordKey(password, salt);
  return ['argon2id', MEMORY_COST, PASSES, PARALLELISM, salt.toString('hex'), hash.toString('hex')].join('$');
}

How is the stored hash assembled?

The hashPassword() function creates a new salt for every password. It stores the algorithm settings plus the salt plus the derived hash in one string.

Those stored settings let the API reproduce the same derivation during login. The original password never enters the database.

  • Add the password verification function directly below hashPassword() by pasting:
export async function verifyPassword(password, storedHash) {
  if (typeof storedHash !== 'string') return false;
  const parts = storedHash.split('$');
  if (parts.length !== 6) return false;
  const [algorithm, memoryValue, passesValue, parallelismValue, saltHex, hashHex] = parts;
  const memory = Number(memoryValue);
  const passes = Number(passesValue);
  const parallelism = Number(parallelismValue);
  if (algorithm !== 'argon2id' || memory !== MEMORY_COST || passes !== PASSES || parallelism !== PARALLELISM || !/^[a-f0-9]{32}$/i.test(saltHex) || !/^[a-f0-9]{64}$/i.test(hashHex)) return false;
  const salt = Buffer.from(saltHex, 'hex');
  const expectedHash = Buffer.from(hashHex, 'hex');
  const actualHash = await derivePasswordKey(password, salt);
  return timingSafeEqual(actualHash, expectedHash);
}

How does verification stay safe?

  • The function rejects stored values with an unexpected format or configuration.
  • It rebuilds the candidate hash with the salt stored beside the original hash.
  • The timingSafeEqual() comparison avoids leaking useful timing differences during the final comparison.
  • Save src/services/password.service.js.
  • Check that Node.js can load the complete service by running this command in the PowerShell terminal:
node --input-type=module --eval "import './src/services/password.service.js'; console.log('Password service loaded')"

What does this check prove?

Node.js loads the service as an ES module. The confirmation prints only after its imports plus syntax have been processed successfully.

You should see Password service loaded in PowerShell. Your hashing service is ready for the registration route.

Password service not loading?

  • Check that the file is located at src/services/password.service.js.
  • Compare each import name with the first three lines of the code.
  • Confirm every function closes with a matching brace.

Ask for help: Help me find the syntax or import problem in my password service.

Add the registration route

A registration boundary has two jobs. It rejects malformed input before database work begins and returns safe user fields after a successful insert.

What happens before the insert?

Normalization trims whitespace from the email before converting it to lowercase. Validation then checks the email format plus the password length.

The route queries the unique email before inserting the row. A duplicate request receives a conflict response without replacing the existing account.

  • Select the New File icon in the Visual Studio Code Explorer sidebar.
  • Enter src/routes/auth.routes.js as the file path.
  • Confirm the file creation.

You should see auth.routes.js inside a new src/routes folder.

  • Build the registration router by pasting this complete file into auth.routes.js:
import express from 'express';
import { eq } from 'drizzle-orm';
import { db } from '../db/index.js';
import { users } from '../db/schema.js';
import { hashPassword } from '../services/password.service.js';

const router = express.Router();

function parseCredentials(body) {
  const email = typeof body?.email === 'string' ? body.email.trim().toLowerCase() : '';
  const password = typeof body?.password === 'string' ? body.password : '';
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { error: 'A valid email is required' };
  if (password.length < 8 || password.length > 128) return { error: 'Password must be between 8 and 128 characters' };
  return { email, password };
}

router.post('/register', async (req, res) => {
  const credentials = parseCredentials(req.body);
  if (credentials.error) return res.status(400).json({ error: credentials.error });
  const [existingUser] = await db.select({ id: users.id }).from(users).where(eq(users.email, credentials.email)).limit(1);
  if (existingUser) return res.status(409).json({ error: 'An account with this email already exists' });
  const passwordHash = await hashPassword(credentials.password);
  const [createdUser] = await db.insert(users).values({ email: credentials.email, passwordHash }).returning({ id: users.id, email: users.email, createdAt: users.createdAt });
  return res.status(201).json({ user: createdUser });
});

export default router;

How does registration work?

  • The parseCredentials() function normalizes the email before enforcing the accepted input rules.
  • The first database query checks whether the normalized email already belongs to a user.
  • The insert stores passwordHash beside the normalized email.
  • The response selects only id, email, and createdAt.
  • Save src/routes/auth.routes.js.
  • Select src/app.js in the Explorer sidebar.
  • Select all existing content in src/app.js.
  • Replace the selected content with this application wiring:
import express from 'express';
import authRouter from './routes/auth.routes.js';

const app = express();
app.use(express.json({ limit: '10kb' }));
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.use('/auth', authRouter);
export default app;

How is the route mounted?

The application imports authRouter before mounting it at /auth. The router's /register path therefore becomes /auth/register.

JSON middleware runs before the route. This gives the handler access to the parsed request body through req.body.

  • Save src/app.js.
  • Stop the earlier server with Ctrl+C if it is still running.

Before you restart the API, do you expect the server to load the new router successfully?

  • Start the updated API by running:
npm start

What does this start now?

The start script loads src/server.js. That file imports the application plus the newly mounted registration router.

You should see the listening message with http://localhost:3000. The terminal remains occupied while the API accepts requests.

Server stopped during startup?

  • Check that the import path in src/app.js ends with ./routes/auth.routes.js.
  • Confirm auth.routes.js imports ../services/password.service.js.
  • Confirm your local .env still contains DATABASE_URL.

Ask for help: Help me trace why my registration router prevents npm start from loading the API.

  • Compare the complete runtime files below with your saved files before testing the endpoint.

✔️ Awesome, I've got everything!

Great. Your password service, registration router, application wiring, database client, and server entry point are ready.

ⓧ I'd like to double check the full code

Compare these five files character by character with your project.

import { argon2, randomBytes, timingSafeEqual } from 'node:crypto';
import { Buffer } from 'node:buffer';
import { promisify } from 'node:util';

const argon2Async = promisify(argon2);
const MEMORY_COST = 19456;
const PASSES = 2;
const PARALLELISM = 1;
const TAG_LENGTH = 32;

async function derivePasswordKey(password, salt) {
  return argon2Async('argon2id', { message: password, nonce: salt, parallelism: PARALLELISM, tagLength: TAG_LENGTH, memory: MEMORY_COST, passes: PASSES });
}

export async function hashPassword(password) {
  const salt = randomBytes(16);
  const hash = await derivePasswordKey(password, salt);
  return ['argon2id', MEMORY_COST, PASSES, PARALLELISM, salt.toString('hex'), hash.toString('hex')].join('$');
}

export async function verifyPassword(password, storedHash) {
  if (typeof storedHash !== 'string') return false;
  const parts = storedHash.split('$');
  if (parts.length !== 6) return false;
  const [algorithm, memoryValue, passesValue, parallelismValue, saltHex, hashHex] = parts;
  const memory = Number(memoryValue);
  const passes = Number(passesValue);
  const parallelism = Number(parallelismValue);
  if (algorithm !== 'argon2id' || memory !== MEMORY_COST || passes !== PASSES || parallelism !== PARALLELISM || !/^[a-f0-9]{32}$/i.test(saltHex) || !/^[a-f0-9]{64}$/i.test(hashHex)) return false;
  const salt = Buffer.from(saltHex, 'hex');
  const expectedHash = Buffer.from(hashHex, 'hex');
  const actualHash = await derivePasswordKey(password, salt);
  return timingSafeEqual(actualHash, expectedHash);
}
import express from 'express';
import { eq } from 'drizzle-orm';
import { db } from '../db/index.js';
import { users } from '../db/schema.js';
import { hashPassword } from '../services/password.service.js';

const router = express.Router();

function parseCredentials(body) {
  const email = typeof body?.email === 'string' ? body.email.trim().toLowerCase() : '';
  const password = typeof body?.password === 'string' ? body.password : '';
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { error: 'A valid email is required' };
  if (password.length < 8 || password.length > 128) return { error: 'Password must be between 8 and 128 characters' };
  return { email, password };
}

router.post('/register', async (req, res) => {
  const credentials = parseCredentials(req.body);
  if (credentials.error) return res.status(400).json({ error: credentials.error });
  const [existingUser] = await db.select({ id: users.id }).from(users).where(eq(users.email, credentials.email)).limit(1);
  if (existingUser) return res.status(409).json({ error: 'An account with this email already exists' });
  const passwordHash = await hashPassword(credentials.password);
  const [createdUser] = await db.insert(users).values({ email: credentials.email, passwordHash }).returning({ id: users.id, email: users.email, createdAt: users.createdAt });
  return res.status(201).json({ user: createdUser });
});

export default router;
import express from 'express';
import authRouter from './routes/auth.routes.js';

const app = express();
app.use(express.json({ limit: '10kb' }));
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.use('/auth', authRouter);
export default app;
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';

if (!process.env.DATABASE_URL) {
  throw new Error('DATABASE_URL is not set in the .env file');
}

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle({ client: pool });
import 'dotenv/config';
import app from './app.js';

const port = Number(process.env.PORT || 3000);
app.listen(port, (error) => {
  if (error) throw error;
  console.log(`User Authentication API listening at http://localhost:${port}`);
});
Test valid and invalid registrations

Positive tests prove the normal registration path works. Negative tests prove the same route rejects malformed input plus duplicate accounts.

  • Switch back to the User Authentication API collection in Postman.
  • Create a request named Register User.
  • Set the request method to POST.
  • Enter {{baseUrl}}/auth/register as the request URL.
  • Select a raw JSON request body.
  • Enter this test account in the request body:
{
  "email": "registration-step@example.com",
  "password": "SecurePass123"
}

What does this body test?

The email has a valid format. The test-only password falls inside the accepted 8-to-128-character range.

  • Select Scripts for the request.
  • Select Post-response.
  • Add this successful-registration assertion:
pm.test("Status code is 201", () => {
  pm.response.to.have.status(201);
});

What does this test check?

Postman runs the assertion after receiving the response. The test passes only when registration returns status 201.

  • Save the Register User request.

Before you send the request, which user fields do you expect the response to reveal?

  • Send the Register User request.

You should receive status 201 with a user object containing an ID plus the email plus a creation timestamp. The response contains no password or hash.

That is the first secure account created by your API. The database holds the credential hash while the client receives only safe profile fields.

  • Create another request named Register Malformed User.
  • Set the request method to POST.
  • Enter {{baseUrl}}/auth/register as the request URL.
  • Select a raw JSON request body.
  • Enter this malformed account data:
{
  "email": "invalid-email",
  "password": "short"
}

Why is this request malformed?

The email fails the route's email-format check. The password is also shorter than eight characters.

  • Select Scripts for the malformed request.
  • Select Post-response.
  • Add this malformed-input assertion:
pm.test("Status code is 400", () => {
  pm.response.to.have.status(400);
});

What does this negative test prove?

The assertion expects status 400. A passing result proves the route rejects invalid input before inserting a user.

  • Save the Register Malformed User request.

Before you send this request, do you expect the database insert to run?

  • Send the Register Malformed User request.

You should receive status 400 with an input error. The Post-response assertion should pass.

  • Create a third request named Register Duplicate User.
  • Set the request method to POST.
  • Enter {{baseUrl}}/auth/register as the request URL.
  • Select a raw JSON request body.
  • Reuse the original account data in the request body:
{
  "email": "registration-step@example.com",
  "password": "SecurePass123"
}

Why reuse the same email?

The first successful request already stored this normalized email. Reusing it exercises the duplicate-account branch.

  • Select Scripts for the duplicate request.
  • Select Post-response.
  • Add this duplicate-account assertion:
pm.test("Status code is 409", () => {
  pm.response.to.have.status(409);
});

What does the conflict test prove?

The assertion expects status 409. This status confirms the existing account prevented a second insert.

  • Save the Register Duplicate User request.

Before you send the duplicate request, do you expect a new user row or a conflict response?

  • Send the Register Duplicate User request.

You should receive status 409 with a duplicate-account error. The Post-response assertion should pass.

Registration test failing?

  • Confirm the API terminal still shows a running server.
  • Check that every request uses {{baseUrl}}/auth/register.
  • Send the valid registration request before sending the duplicate request.

Ask for help: Help me diagnose which registration branch produced the wrong Postman status.

Before you inspect the saved user, what should the password_hash value look like compared with the test password?

  • Return to the Tables view for your Neon project.
  • Select the users table.
  • Find the row for registration-step@example.com.
  • Compare its password_hash value with the test password.

You should see a long Argon2id value instead of SecurePass123. You should also see exactly one row for the test email.

Your API can now register users without storing plaintext passwords. Next, you will verify those hashes during login and issue signed access tokens.

Add Login and Issue Tokens

Registration now validates credentials before your Express API stores an Argon2id password hash in Neon through Drizzle ORM.

A signed JWT lets a client carry proof of a successful login. You will issue that token before using Postman to test whether the temporary profile route requires it.

In this step, get ready to:
  • Generate a private signing secret.
  • Issue signed tokens after successful credential verification.
  • Test the temporary profile route without authentication.
Generate the signing secret and token service

A signed JWT provides integrity for its claims. The token payload remains readable to anyone who holds it.

What Do the JWT Claims Mean?

  • The sub claim identifies the user represented by the token.
  • The iss claim identifies the API that issued the token.
  • The aud claim identifies the intended recipient.
  • The iat claim records the issue time.
  • The exp claim sets the expiration time.

The signing secret acts as a credential for your API. Your existing .gitignore keeps the local .env file outside source control.

  • Switch back to the PowerShell terminal in Visual Studio Code.
  • Stop the running API by pressing Ctrl+C.
  • Generate a 64-character hexadecimal secret by running this command:
npm run generate:secret

What Does This Command Do?

The generate:secret package script runs scripts/generate-secret.js. The script converts 32 random bytes into 64 hexadecimal characters.

You should see one 64-character hexadecimal value. Treat this value like a password for signing tokens.

  • Copy the generated value without sharing it.
  • Return to the existing .env file in Visual Studio Code.
  • Add JWT_SECRET= on a new line.
  • Paste the generated value after the equals sign.
  • Save .env.

Secret Command Not Working?

  • Check that PowerShell is inside the user-authentication-api folder.
  • Check that package.json still contains the generate:secret script.
  • Check that scripts/generate-secret.js still generates 32 random bytes.

Ask for help: Help me debug the secret-generation script without exposing my secret.

The token service centralizes the signing policy. Token creation and token verification use the same key format.

  • Select the existing src/services folder in the Visual Studio Code Explorer.
  • Click the New File icon in the Explorer toolbar.
  • Enter token.service.js as the filename.
  • Add the signing-key setup by pasting this first part:
import { Buffer } from 'node:buffer';
import { SignJWT, jwtVerify } from 'jose';

const ISSUER = 'user-authentication-api';
const AUDIENCE = 'user-authentication-api';

function getJwtSecret() {
  const secret = process.env.JWT_SECRET;

  if (!secret || !/^[a-f0-9]{64}$/i.test(secret)) {
    throw new Error('JWT_SECRET must contain exactly 64 hexadecimal characters');
  }

  return Buffer.from(secret, 'hex');
}

How Is the Signing Key Prepared?

  • The issuer constant identifies this API as the token creator.
  • The audience constant identifies this API as the intended token recipient.
  • The getJwtSecret() function rejects a missing secret.
  • The hexadecimal check rejects an incorrectly formatted secret.
  • The Buffer conversion prepares the key for cryptographic operations.
  • Add the token creation function below getJwtSecret().
  • Add the token verification function immediately after it by pasting:
export function createAccessToken(userId) {
  return new SignJWT({})
    .setProtectedHeader({ alg: 'HS256' })
    .setSubject(userId)
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE)
    .setIssuedAt()
    .setExpirationTime('15m')
    .sign(getJwtSecret());
}

export async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, getJwtSecret(), {
    algorithms: ['HS256'],
    issuer: ISSUER,
    audience: AUDIENCE,
  });

  return payload;
}

How Are Tokens Created and Checked?

  • The createAccessToken() function places the user ID in the sub claim.
  • The signing chain adds the issuer claim.
  • The signing chain adds the audience claim.
  • The token expires after 15 minutes.
  • The verifyAccessToken() function accepts only HS256 signatures.
  • Save src/services/token.service.js.
  • Start the existing API by running this command:
npm start

What Should the Server Report?

You should see User Authentication API listening at http://localhost:3000 in PowerShell. This confirms the existing API still starts with the new environment value saved.

Server Not Starting?

  • Check that the local .env file contains the JWT_SECRET entry.
  • Check that the secret contains exactly 64 hexadecimal characters.
  • Check that token.service.js is inside src/services.

Ask for help: Help me check the signing-secret setup without revealing its value.

✔️ Awesome, I've got everything!

Your signing secret is stored locally. Double check that src/services/token.service.js is saved.

ⓧ I'd like to double check the full code

Compare your token service with this complete file.

import { Buffer } from 'node:buffer';
import { SignJWT, jwtVerify } from 'jose';

const ISSUER = 'user-authentication-api';
const AUDIENCE = 'user-authentication-api';

function getJwtSecret() {
  const secret = process.env.JWT_SECRET;

  if (!secret || !/^[a-f0-9]{64}$/i.test(secret)) {
    throw new Error('JWT_SECRET must contain exactly 64 hexadecimal characters');
  }

  return Buffer.from(secret, 'hex');
}

export function createAccessToken(userId) {
  return new SignJWT({})
    .setProtectedHeader({ alg: 'HS256' })
    .setSubject(userId)
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE)
    .setIssuedAt()
    .setExpirationTime('15m')
    .sign(getJwtSecret());
}

export async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, getJwtSecret(), {
    algorithms: ['HS256'],
    issuer: ISSUER,
    audience: AUDIENCE,
  });

  return payload;
}

What Should Match?

Check the issuer value. Check the audience value.

Check the 15-minute expiration. Check the explicit HS256 verification rule.

Add the login route and save its token

Login reuses the credential parser from registration. The submitted password is checked against the stored Argon2id value with verifyPassword().

Every unknown email or incorrect password receives the same response. This generic failure avoids revealing which email addresses have accounts.

  • Stop the running API by pressing Ctrl+C.
  • Return to src/routes/auth.routes.js.
  • Find the current password-service import:
import { hashPassword } from '../services/password.service.js';

What Does the Current Import Support?

Registration uses hashPassword() to create a stored hash. Login also needs the corresponding verification function.

  • Replace the current password-service import with these two imports:
import { hashPassword, verifyPassword } from '../services/password.service.js';
import { createAccessToken } from '../services/token.service.js';

What Do the New Imports Provide?

The password service compares the submitted password with the stored hash. The token service signs a JWT after that comparison succeeds.

The login route is added in two short pieces to keep each code block readable. Keep both pieces together below the registration route.

  • Add the first part of the login route below the registration route by pasting:
router.post('/login', async (req, res) => {
  const credentials = parseCredentials(req.body);

  if (credentials.error) {
    return res.status(400).json({ error: credentials.error });
  }

  const [user] = await db
    .select({
      id: users.id,
      email: users.email,
      passwordHash: users.passwordHash,
    })
    .from(users)
    .where(eq(users.email, credentials.email))
    .limit(1);

What Does the First Part Do?

The route validates the request with parseCredentials(). The database query retrieves the matching user's password hash for verification.

  • Complete the login route immediately below the query by pasting:
  const passwordIsValid = user
    ? await verifyPassword(credentials.password, user.passwordHash)
    : false;

  if (!user || !passwordIsValid) {
    return res.status(401).json({ error: 'Invalid email or password' });
  }

  const token = await createAccessToken(user.id);

  return res.json({
    token,
    user: {
      id: user.id,
      email: user.email,
    },
  });
});

What Does the Second Part Do?

The conditional password check stays false when no user exists. The generic 401 branch covers both invalid credential cases.

A valid password produces a signed token. The response exposes only the token plus the safe user ID and email.

  • Save src/routes/auth.routes.js.
  • Start the updated API by running this command:
npm start

What Does the Restart Load?

You should see the listening message again. The restart loads the new token-service import and the login route.

Login Route Blocking Startup?

  • Check that both service imports end with .js.
  • Check that the login route appears before export default router;.
  • Check that JWT_SECRET contains exactly 64 hexadecimal characters.

Ask for help: Help me find why the login route prevents the API from starting.

The API can now prove both outcomes of credential verification. The successful request also stores its returned token for the profile flow.

  • Switch back to the existing User Authentication API collection in Postman.
  • Duplicate the successful registration request.
  • Rename the duplicate Successful Login.
  • Keep POST as the request method.
  • Enter {{baseUrl}}/auth/login as the request URL.
  • Leave the registered email in the JSON body.
  • Leave the matching password in the JSON body.
  • Select the Scripts tab.
  • Select the Post-response tab.
  • Add the status assertion and token-saving script by pasting:
pm.test("Status code is 200", function () { pm.response.to.have.status(200); });
pm.collectionVariables.set('token', pm.response.json().token);

What Does the Post-Response Script Do?

The first line checks for a 200 response. The second line saves the returned token as the collection variable token.

  • Save the Successful Login request.
  • Click Send.

You should see a 200 response containing a token. You should also see the safe user ID and email.

That is the login exchange working. Verified credentials now produce a signed token that Postman can reuse.

  • Duplicate the Successful Login request.
  • Rename the duplicate Failed Login.
  • Change one character in the password value.
  • Remove the line that saves the collection variable.
  • Replace the status assertion with this test:
pm.test("Status code is 401", function () {
  pm.response.to.have.status(401);
});

What Does This Test Check?

The test expects the API to reject the changed password with 401. The request does not overwrite the valid saved token.

  • Save the Failed Login request.
  • Click Send.

You should see a 401 response containing Invalid email or password. The response does not identify which credential failed.

Login Tests Not Passing?

  • Reuse the exact email created by the successful registration request.
  • Restore the original password in the successful login request.
  • Check that only the successful request saves pm.response.json().token.

Ask for help: Help me compare my registration record with both login requests.

✔️ Awesome, I've got everything!

Your login route verifies stored password hashes. The successful Postman request saves its returned token.

ⓧ I'd like to double check the full code

Compare src/routes/auth.routes.js with this cumulative file.

import express from 'express';
import { eq } from 'drizzle-orm';
import { db } from '../db/index.js';
import { users } from '../db/schema.js';
import { hashPassword, verifyPassword } from '../services/password.service.js';
import { createAccessToken } from '../services/token.service.js';

const router = express.Router();

function parseCredentials(body) {
  const email = typeof body?.email === 'string' ? body.email.trim().toLowerCase() : '';
  const password = typeof body?.password === 'string' ? body.password : '';

  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return { error: 'A valid email is required' };
  }

  if (password.length < 8 || password.length > 128) {
    return { error: 'Password must be between 8 and 128 characters' };
  }

  return { email, password };
}

router.post('/register', async (req, res) => {
  const credentials = parseCredentials(req.body);

  if (credentials.error) {
    return res.status(400).json({ error: credentials.error });
  }

  const [existingUser] = await db
    .select({ id: users.id })
    .from(users)
    .where(eq(users.email, credentials.email))
    .limit(1);

  if (existingUser) {
    return res.status(409).json({ error: 'An account with this email already exists' });
  }

  const passwordHash = await hashPassword(credentials.password);
  const [createdUser] = await db
    .insert(users)
    .values({
      email: credentials.email,
      passwordHash,
    })
    .returning({
      id: users.id,
      email: users.email,
      createdAt: users.createdAt,
    });

  return res.status(201).json({ user: createdUser });
});

router.post('/login', async (req, res) => {
  const credentials = parseCredentials(req.body);

  if (credentials.error) {
    return res.status(400).json({ error: credentials.error });
  }

  const [user] = await db
    .select({
      id: users.id,
      email: users.email,
      passwordHash: users.passwordHash,
    })
    .from(users)
    .where(eq(users.email, credentials.email))
    .limit(1);

  const passwordIsValid = user
    ? await verifyPassword(credentials.password, user.passwordHash)
    : false;

  if (!user || !passwordIsValid) {
    return res.status(401).json({ error: 'Invalid email or password' });
  }

  const token = await createAccessToken(user.id);

  return res.json({
    token,
    user: {
      id: user.id,
      email: user.email,
    },
  });
});

export default router;

What Should Match?

Check the two service imports. Check the generic 401 branch.

Check that the successful response contains the token. Check that the user object contains only the ID and email.

Add the temporary profile route

The next request needs a profile endpoint to call after login. This temporary version selects the first user's safe fields from the database.

  • Select the existing src/routes folder in the Visual Studio Code Explorer.
  • Click the New File icon in the Explorer toolbar.
  • Enter user.routes.js as the filename.
  • Add the temporary profile route by pasting:
import express from 'express';
import { db } from '../db/index.js';
import { users } from '../db/schema.js';

const router = express.Router();

router.get('/me', async (req, res) => {
  const [user] = await db
    .select({
      id: users.id,
      email: users.email,
      createdAt: users.createdAt,
    })
    .from(users)
    .limit(1);

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  return res.json({ user });
});

export default router;

What Does the Temporary Route Return?

The query selects the first user row. Its selected fields exclude passwordHash.

The route returns 404 when no user exists. Otherwise, it returns the selected user object.

  • Save src/routes/user.routes.js.
  • Return to src/app.js.
  • Replace its contents with this cumulative version:
import express from 'express';
import authRouter from './routes/auth.routes.js';
import userRouter from './routes/user.routes.js';

const app = express();
app.use(express.json({ limit: '10kb' }));
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.use('/auth', authRouter);
app.use('/users', userRouter);
export default app;

How Is the Profile Route Mounted?

The application mounts userRouter at /users. The router's /me path becomes /users/me.

  • Save src/app.js.
  • Restart the updated API by running this command:
npm start

What Does the Restart Confirm?

You should see User Authentication API listening at http://localhost:3000 again. The server has loaded the new user router.

User Router Not Loading?

  • Check that user.routes.js is inside src/routes.
  • Check that the user-router import ends with .js.
  • Check that app.use('/users', userRouter); appears before export default app;.

Ask for help: Help me debug why the new user router does not load.

  • Return to the existing User Authentication API collection in Postman.
  • Create a request named Temporary Public Profile.
  • Set GET as the request method.
  • Enter {{baseUrl}}/users/me as the request URL.
  • Select the Authorization tab.
  • Select No Auth as the authorization type.
  • Save the request.

Before you send this request, do you think the API returns a profile or rejects the missing token?

  • Click Send.

You will see a 200 response containing the first user's safe profile fields. The request succeeds even though Postman sent no authentication.

This is the intended security shortfall. The API issues signed tokens, yet the temporary profile route never checks one.

  • Select the Scripts tab in the profile request.
  • Select the Post-response tab.
  • Record the observed behavior by pasting this status assertion:
pm.test("Status code is 200", function () { pm.response.to.have.status(200); });

What Does This Test Prove?

The assertion captures the temporary route's current behavior. It passes while a request with No Auth receives 200.

  • Save the Temporary Public Profile request.
  • Click Send again.

You should see the status test pass. Your Postman collection now preserves evidence of the public-profile flaw.

Profile Request Not Returning a User?

  • Check that a successful registration row still exists in Neon.
  • Check that src/app.js mounts userRouter at /users.
  • Check that Postman uses No Auth for this request.

Ask for help: Help me debug the temporary profile request.

✔️ Awesome, I've got everything!

Your temporary profile route is mounted. Postman proves that it currently returns a user without authentication.

ⓧ I'd like to double check the full code

Compare the temporary user router with this complete file.

import express from 'express';
import { db } from '../db/index.js';
import { users } from '../db/schema.js';

const router = express.Router();

router.get('/me', async (req, res) => {
  const [user] = await db
    .select({
      id: users.id,
      email: users.email,
      createdAt: users.createdAt,
    })
    .from(users)
    .limit(1);

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  return res.json({ user });
});

export default router;

What Should the Temporary Router Contain?

The selected fields are id, email, and createdAt. The query returns the first available row.

Compare the application entry point with this cumulative file.

import express from 'express';
import authRouter from './routes/auth.routes.js';
import userRouter from './routes/user.routes.js';

const app = express();
app.use(express.json({ limit: '10kb' }));
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.use('/auth', authRouter);
app.use('/users', userRouter);
export default app;

What Should the App Mount?

The application mounts authRouter at /auth. It mounts userRouter at /users.

Step Checkpoint

  • Your local .env file holds a generated 64-character hexadecimal JWT_SECRET.
  • The token service signs 15-minute HS256 tokens.
  • The login route returns a generic 401 for invalid credentials.
  • The successful login request saves its token in the collection variable token.
  • The temporary profile request receives 200 while using No Auth.

Your API can issue signed login tokens, while the profile route still ignores them. Next, you will turn token verification into the gate that protects each profile.

Protect the Profile Endpoint

Your Express API already issues a signed JWT after a successful login. The temporary profile route still returns private user data to every caller.

This step adds authentication through reusable middleware. The route then uses the verified token subject to retrieve only the authenticated user.

In this step, get ready to:
  • Build middleware that requires and verifies a bearer token.
  • Replace the public profile route with an authenticated user query.
  • Prove the protected flow with positive and negative Postman tests.
Build the authentication gate

Authentication establishes who sent a request. Authorization decides what that identity can access.

How does the middleware protect the route?

Express runs route middleware from left to right. The profile handler runs only after the authentication function calls next().

The verified JWT subject becomes req.auth.userId. This gives later handlers one trusted identity source.

  • Switch back to Visual Studio Code from earlier.
  • Create a folder named middleware inside the src folder using the Explorer sidebar.
  • Create authenticate.js inside the new src/middleware folder.
  • Add the authentication gate to src/middleware/authenticate.js by pasting this code:
import { verifyAccessToken } from '../services/token.service.js';

export async function authenticate(req, res, next) {
  const authorization = req.headers.authorization;

  if (typeof authorization !== 'string' || !authorization.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Authentication required' });
  }

  const token = authorization.slice(7);

  try {
    const payload = await verifyAccessToken(token);

    if (typeof payload.sub !== 'string') {
      return res.status(401).json({ error: 'Invalid or expired token' });
    }

    req.auth = { userId: payload.sub };
    return next();
  } catch {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

What does this middleware do?

  • The authorization value reads the request's authorization header.
  • The first condition requires the header to start with Bearer . Missing or malformed authentication receives 401.
  • The slice(7) call removes the bearer prefix before verification.
  • The existing verifyAccessToken() function verifies the signature against the configured algorithm. It also checks the expected issuer and audience.
  • A valid string in payload.sub becomes req.auth.userId for the protected route.
  • Expired or invalid tokens reach the catch block. They receive the same 401 response.
  • Save src/middleware/authenticate.js.
  • Confirm that authenticate.js appears inside src/middleware in the Explorer sidebar.

Missing the middleware file?

Check that middleware sits inside src. The import path in the profile route depends on that location.

Ask for help: Help me check why my authenticate.js file or verifyAccessToken import cannot be found.

Secure the profile query

The middleware proves the caller's identity. The profile route can now use that identity in its Drizzle ORM query.

  • Select src/routes/user.routes.js in the Explorer sidebar.
  • Replace the temporary public route with this protected implementation:
import express from 'express';
import { eq } from 'drizzle-orm';
import { db } from '../db/index.js';
import { users } from '../db/schema.js';
import { authenticate } from '../middleware/authenticate.js';

const router = express.Router();

router.get('/me', authenticate, async (req, res) => {
  const [user] = await db
    .select({
      id: users.id,
      email: users.email,
      createdAt: users.createdAt,
    })
    .from(users)
    .where(eq(users.id, req.auth.userId))
    .limit(1);

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  return res.json({ user });
});

export default router;

How is the profile now secured?

  • The route places authenticate before the async handler. Express stops before the query whenever authentication fails.
  • The where() condition matches the database user ID against req.auth.userId.
  • The selection includes only id, email, and createdAt.
  • The query never selects passwordHash. The value cannot leak through this response.
  • A valid token can outlive a deleted user record. The 404 branch handles that state.
  • Save src/routes/user.routes.js.
  • Select src/app.js in the Explorer sidebar.
  • Replace its contents with the final router and error-handling order:
import express from 'express';
import authRouter from './routes/auth.routes.js';
import userRouter from './routes/user.routes.js';

const app = express();

app.use(express.json({ limit: '10kb' }));

app.get('/health', (req, res) => {
  return res.json({ status: 'ok' });
});

app.use('/auth', authRouter);
app.use('/users', userRouter);

app.use((req, res) => {
  return res.status(404).json({ error: 'Route not found' });
});

app.use((error, req, res, next) => {
  if (res.headersSent) {
    return next(error);
  }

  console.error(error);
  return res.status(500).json({ error: 'Internal server error' });
});

export default app;

Why does middleware order matter?

  • The JSON parser runs before routes that need request bodies.
  • The health route remains available without authentication.
  • The authRouter handles registration and login under /auth.
  • The userRouter handles the protected profile under /users.
  • The final two handlers catch unmatched routes and unexpected server errors.
  • Save src/app.js.
  • Return to the PowerShell window running the API.
  • Stop the previous server process with Ctrl+C.
  • Start the updated API by running:
npm start

What should the server show?

You should see User Authentication API listening at http://localhost:3000. PowerShell remains occupied while the server listens for requests.

Server failing after the changes?

Check that the middleware file is named authenticate.js. Confirm that it exports authenticate.

Check that user.routes.js imports the middleware from ../middleware/authenticate.js.

Ask for help: Help me debug the startup error after adding authentication middleware and the protected user route.

✔️ Awesome, I've got everything!

Your middleware, protected route, and application router order are saved. Keep the updated server running for the Postman checks.

ⓧ I'd like to double check the full code

Compare these three files with your project. Each filename and import path must match exactly.

import { verifyAccessToken } from '../services/token.service.js';

export async function authenticate(req, res, next) {
  const authorization = req.headers.authorization;

  if (typeof authorization !== 'string' || !authorization.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Authentication required' });
  }

  const token = authorization.slice(7);

  try {
    const payload = await verifyAccessToken(token);

    if (typeof payload.sub !== 'string') {
      return res.status(401).json({ error: 'Invalid or expired token' });
    }

    req.auth = { userId: payload.sub };
    return next();
  } catch {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}
import express from 'express';
import { eq } from 'drizzle-orm';
import { db } from '../db/index.js';
import { users } from '../db/schema.js';
import { authenticate } from '../middleware/authenticate.js';

const router = express.Router();

router.get('/me', authenticate, async (req, res) => {
  const [user] = await db
    .select({
      id: users.id,
      email: users.email,
      createdAt: users.createdAt,
    })
    .from(users)
    .where(eq(users.id, req.auth.userId))
    .limit(1);

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  return res.json({ user });
});

export default router;
import express from 'express';
import authRouter from './routes/auth.routes.js';
import userRouter from './routes/user.routes.js';

const app = express();

app.use(express.json({ limit: '10kb' }));

app.get('/health', (req, res) => {
  return res.json({ status: 'ok' });
});

app.use('/auth', authRouter);
app.use('/users', userRouter);

app.use((req, res) => {
  return res.status(404).json({ error: 'Route not found' });
});

app.use((error, req, res, next) => {
  if (res.headersSent) {
    return next(error);
  }

  console.error(error);
  return res.status(500).json({ error: 'Internal server error' });
});

export default app;
Prove the full protected flow

A protected endpoint needs a negative test and a positive test. Postman first proves that the API rejects a request with no identity.

  • Return to the Postman desktop app from earlier.
  • Select the existing temporary GET {{baseUrl}}/users/me request in the User Authentication API collection.
  • Select No Auth in the Authorization tab.
  • Select the Scripts tab.
  • Select Post-response.
  • Open Snippets in the script editor.
  • Select Status code: Code is 200.
  • Replace both 200 values in the generated assertion with 401.
  • Save the unauthenticated profile request.

Before you send the request, do you expect the profile data to remain public?

  • Send the unauthenticated profile request by clicking Send.
  • Open Test Results in the response area.

You should see a 401 response containing Authentication required. The status test should pass.

That rejection closes the public profile flaw. Callers now need a verified identity before the database query runs.

Still receiving a public profile?

Confirm that router.get('/me', authenticate, async (req, res) => { includes authenticate before the handler.

Restart the API after saving the files. An older server process can continue serving the temporary route.

Ask for help: Help me find why GET /users/me still returns 200 without authentication.

The positive test proves that a valid bearer token reaches the authenticated user query. The collection variable from your successful login already holds the token.

  • Add a new request inside the User Authentication API collection.
  • Name the new request Profile - Authenticated.
  • Set the request method to GET.
  • Enter {{baseUrl}}/users/me as the request URL.
  • Select the Authorization tab.
  • Select Bearer Token as the authorization type.
  • Enter {{token}} in the token field.
  • Select the Scripts tab.

What does Postman send?

Postman reads {{token}} from the collection variable. It sends that value with the bearer authorization scheme.

The API verifies the signature and required claims before trusting the token's subject.

  • Select Post-response.
  • Open Snippets in the script editor.
  • Select Status code: Code is 200.
  • Save the authenticated profile request.

Before you send the authenticated request, which user do you expect the verified subject to select?

  • Send the authenticated profile request by clicking Send.
  • Open Test Results in the response area.

You should see a 200 response with a user object. It should contain id, email, and createdAt.

The response should not contain passwordHash. Your API now returns the authenticated user's safe profile fields.

Authenticated request returning 401?

Run the successful login request again so the collection variable contains a current token. Confirm that the login response test still passes.

Check that the token field contains {{token}} with the same capitalization as the saved collection variable.

Ask for help: Help me debug why my saved Postman bearer token receives 401 from GET /users/me.

A collection run checks that registration still feeds login. It also proves that login feeds the protected profile request.

Use this request order

  • Run GET /health first.
  • Run the valid POST /auth/register request.
  • Run the malformed registration request.
  • Run the duplicate registration request.
  • Run the successful POST /auth/login request.
  • Run the failed login request.
  • Run the profile request with No Auth.
  • Run the profile request with Bearer Token.
  • Choose an unused email address for this run and record it here: your fresh test email.
  • Set the valid registration request's email field to your fresh test email.
  • Set the duplicate registration request's email field to your fresh test email.
  • Set the successful login request's email field to your fresh test email.
  • Set the failed login request's email field to your fresh test email.
  • Open the Collection Runner for the User Authentication API collection.
  • Arrange the selected requests in the order shown above.

Before you start the run, do you expect both profile requests to pass even though they expect different status codes?

  • Run the full collection.

You should see the expected tests pass across health, registration, login, and both profile requests. The unauthenticated profile expects 401. The authenticated profile expects 200.

You have completed the authentication boundary. The same endpoint now rejects anonymous callers and returns a sanitized profile to the verified user.

Secret mission

Break Your Own Token

A valid JWT opens your protected profile. Change one character in its signature to create a tampered token. Then prove that your API rejects the modified token while the original still works.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

The Neon Free plan costs $0 per month. Postman Free covers the collection workflow used in this project.

Resources you used:

  • A local Node.js project in the user-authentication-api folder on your Desktop. It contains the installed npm dependencies.
  • The Postman collection named User Authentication API. It contains saved requests plus collection variables such as baseUrl, token, and tamperedToken.
  • One persistent Neon project containing the users table.

Keep everything running

No action needed. Choose this if you are still actively building or want to test more authentication scenarios.

  • Keep the user-authentication-api folder on your Desktop for future experiments.
  • Keep the User Authentication API collection for rerunning registration, login, profile, and tampered-token tests.
  • Keep the Neon project available so your registered users remain in the users table.
  • Leave the local server running while you still want to send requests to the API.

Pause - I'll come back to this later

Shut down the local server to free its port while preserving every reusable project resource.

  • Press Ctrl+C in the PowerShell window running the API.
  • Keep the user-authentication-api folder on your Desktop.
  • Keep the User Authentication API collection in Postman.
  • Leave the Neon project idle.

Neon Free compute suspends after five minutes of inactivity. That short wait is expected.

Delete - I don't want to use this again

Remove all project resources to start fresh. Neon keeps a seven-day recovery window after project deletion.

Delete the Neon project:

  • Return to the Neon Console.
  • Select the project that contains the users table.
  • Open Settings.
  • Select General.
  • Find Delete project.
  • Select Delete project.
  • Type your project name when prompted.

Neon removes the project's computes, branches, databases, and roles. This also removes the users table.

Delete the Postman collection:

The collection contains your reusable requests plus saved token variables. Treat this deletion as permanent.

  • Return to Postman.
  • Find User Authentication API in your collections.
  • Open the collection options menu.
  • Choose the option that deletes the collection.
  • Confirm the collection deletion.

The User Authentication API collection disappears from your collection list. Its requests plus the token and tamperedToken collection variables are removed with it.

Delete the local project:

The local folder contains your source code plus installed dependencies. Keep a copy elsewhere before deleting it if you want to revisit the API.

  • Stop the local server by pressing Ctrl+C in its PowerShell window.
  • Use your desktop file manager to delete the user-authentication-api folder from your Desktop.
  • Return to your Desktop in the file manager.

You should no longer see the user-authentication-api folder. Its source files plus local npm dependencies have been removed.

Nice Work!

Nice Work!

Excellent work! Your finished API now supports a complete protected account flow.

You learned how to:

  • Built an Express.js API that returns observable responses for its full account flow in Postman.
  • Stored each user in Neon Postgres through Drizzle ORM with an Argon2id password hash.
  • Protected profile data with reusable middleware that verifies each signed JWT before the route runs.
  • Secret Mission: Proved JWT signature integrity by showing that a one-character modification changes the protected profile response from 200 to 401.

Ready to quiz yourself?