NodeJSImplementing JWT Auth

Implementing JWT Auth

With the JWT concepts in hand, let's build a working flow in Express: a login endpoint that signs a token, middleware that verifies it on protected routes, and the error handling that keeps it secure. We'll use jsonwebtoken, the de facto library. The whole pattern is small — the discipline is in pinning the algorithm, setting a short expiry, returning the right status codes, and never leaking why verification failed.

Install and configure

Bash
npm install jsonwebtoken bcrypt

config — load the secret from the environment, never hard-code it

JS
import jwt from 'jsonwebtoken'

const JWT_SECRET = process.env.JWT_SECRET          // long, random, secret
const JWT_EXPIRES_IN = '15m'                        // short-lived access token
if (!JWT_SECRET) throw new Error('JWT_SECRET is not set')
The signing secret lives in env/secrets manager — never in source
Anyone with `JWT_SECRET` can forge tokens for any user, including admins. Treat it like a root password: load it from an environment variable or secrets manager, keep it out of git, make it long and random (32+ bytes), and rotate it if it might be exposed. Committing a JWT secret to a repo is a critical incident — and a frequent one.
Login: verify credentials, sign a token

JS
app.post('/login', async (req, res) => {
  const { email, password } = req.body
  const user = await db.users.findByEmail(email)

  // Generic failure — don't reveal which part was wrong:
  const ok = user && await bcrypt.compare(password, user.passwordHash)
  if (!ok) return res.status(401).json({ error: 'Invalid email or password' })

  // Sign minimal claims — an id and what authorization needs:
  const token = jwt.sign(
    { sub: user.id, role: user.role },              // payload (public! no secrets)
    JWT_SECRET,
    { expiresIn: JWT_EXPIRES_IN },                  // sets 'exp'
  )
  res.json({ token })
})
Sign only an id and authorization claims — the payload is readable
The `jwt.sign` payload becomes the token's body, which [anyone can decode](/nodejs/jwt). Put the **subject** (`sub`: user id) and any claim you need for authorization (like `role`) — and nothing sensitive. `expiresIn` adds the `exp` claim for you. Keep tokens short-lived; for "stay logged in," issue a [refresh token](/nodejs/refresh-tokens) alongside this access token rather than extending its life.
The verify middleware

JS
export function authenticate(req, res, next) {
  const header = req.headers.authorization || ''
  const [scheme, token] = header.split(' ')

  if (scheme !== 'Bearer' || !token) {
    return res.status(401).json({ error: 'Missing or malformed token' })
  }

  try {
    // Pin the algorithm — never trust the token's own 'alg' header:
    const payload = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] })
    req.user = { id: payload.sub, role: payload.role }   // attach identity
    next()
  } catch (err) {
    // Expired vs invalid — return 401 either way, don't leak specifics:
    return res.status(401).json({ error: 'Invalid or expired token' })
  }
}

// Use it on protected routes:
app.get('/me', authenticate, (req, res) => res.json({ id: req.user.id }))
`jwt.verify` throws — you MUST wrap it in try/catch and pin `algorithms`
`jwt.verify` throws on a bad signature, an expired token, or a malformed token — an uncaught throw becomes a 500 and may leak internals. Wrap it in `try/catch` and respond `401`. Always pass `{ algorithms: ['HS256'] }` (or your chosen alg) so an attacker can't downgrade to `alg: none` or pull off [algorithm confusion](/nodejs/jwt). And only attach to `req.user` *after* a successful verify — never trust a decoded-but-unverified token.
The Bearer scheme

Text
Client sends the token in the Authorization header:

  GET /me HTTP/1.1
  Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0Mi...

"Bearer" = whoever bears (holds) this token is treated as the user.
That's exactly why it must travel over HTTPS and expire quickly.
Bearer tokens grant access to whoever holds them — protect them in transit
There's no additional proof tying a bearer token to a specific client — possession *is* authentication. So a token sent over plain HTTP, logged to a file, or stuffed in a URL query string is a credential leak. Send it only over **HTTPS**, in the `Authorization` header (not the URL), keep expiry short, and avoid logging request headers that contain it.
Decode vs verify — a critical difference

JS
jwt.decode(token)   // ❌ reads claims WITHOUT checking the signature — never trust this
jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] })  // ✅ checks signature + exp
`jwt.decode` does NOT validate — only `jwt.verify` can be trusted for auth
`jwt.decode` just Base64-decodes the payload; it performs **no signature check**, so a forged or tampered token decodes happily. Using `decode`'s output for authorization is equivalent to having no auth at all — an attacker hands you any claims they like. Only ever make access decisions based on `jwt.verify`. Reserve `decode` for non-security uses like reading `exp` on the client to decide when to refresh.
Implementation checklist
  • Secret from env, long and random; rotate if leaked.

  • Pin algorithms on every verify call.

  • Short expiresIn on access tokens; pair with refresh tokens.

  • Wrap verify in try/catch401; never 500 on a bad token.

  • HTTPS only, token in the Authorization header, never in URLs or logs.

  • Minimal payload — id + authorization claims, nothing sensitive.

  • Combine with RBAC — authentication via JWT, authorization via role checks.

Authentication and authorization are separate middlewares — compose them
`authenticate` answers *who are you?* by verifying the token and setting `req.user`. It does **not** decide *what you may do* — that's a second, separate check (e.g. `requireRole('admin')`) that runs after it. Keep them as distinct middlewares so routes read clearly: `app.delete('/users/:id', authenticate, requireRole('admin'), handler)`. [Protecting routes](/nodejs/protecting-routes) and [RBAC](/nodejs/rbac) build directly on this split.
Next
Keep users logged in without long-lived access tokens: [Access & Refresh Tokens](/nodejs/refresh-tokens).