Skip to content

auth-patterns

JWT, OAuth, session management, and token refresh implementations with security-correct patterns

From plugin
vexjoy-agent
413198 skills198 agents10 commands86 hooks
Install
$ npx -y skills add notque/vexjoy-agent --agent claude-code

How it fires

How this agent gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.

Context preview

The summary Claude sees to decide when to auto-load this agent.

JWT, OAuth, session management, and token refresh implementations with security-correct patterns

Agent definition

auth-patterns.md
description: JWT, OAuth, session management, and token refresh implementations with security-correct patterns

Authentication Patterns for Node.js APIs

> **Scope**: JWT-based auth, OAuth 2.0 integration, session management, and password security for Express/Next.js APIs. Does not cover frontend auth flows or mobile OAuth. > **Version range**: Node.js 18+, `jsonwebtoken` 9.0+, `bcrypt` 5.0+ > **Generated**: 2026-04-08

---

Overview

Auth fails in two ways: insecure defaults (long-lived tokens, weak secrets, missing expiry) and broken error handling (timing attacks via username enumeration, stack traces in 401 responses).

---

Pattern Table

| Pattern | Version | Use When | Avoid When | |---------|---------|----------|------------| | `jwt.sign()` with `expiresIn` | jsonwebtoken 9+ | All access token issuance | Never omit — tokens live forever | | `jwt.verify()` with `algorithms` | jsonwebtoken 9+ | Token validation | `jwt.decode()` — no signature check | | `bcrypt.hash()` with cost 12 | bcrypt 5+ | Password storage | `md5`, `sha1`, any fast hash | | `crypto.timingSafeEqual()` | Node 16+ | Webhook signature comparison | `===` string comparison | | Refresh token rotation | — | Session persistence beyond 15min | Single long-lived access token |

---

Correct Patterns

JWT Issuance with Short Expiry and Refresh

import jwt from 'jsonwebtoken';
import { randomUUID } from 'crypto';

const ACCESS_TOKEN_TTL = '15m';
const REFRESH_TOKEN_TTL = '7d';

interface TokenPair {
  accessToken: string;
  refreshToken: string;
  expiresIn: number; // seconds
}

function issueTokenPair(userId: string, roles: string[]): TokenPair {
  const accessToken = jwt.sign(
    { sub: userId, roles },
    process.env.JWT_SECRET!,
    {
      algorithm: 'HS256',
      expiresIn: ACCESS_TOKEN_TTL,
      issuer: 'api.example.com',
      jwtid: randomUUID(), // Unique ID for revocation
    }
  );

  const refreshToken = jwt.sign(
    { sub: userId, type: 'refresh' },
    process.env.REFRESH_SECRET!,
    { algorithm: 'HS256', expiresIn: REFRESH_TOKEN_TTL }
  );

  return { accessToken, refreshToken, expiresIn: 15 * 60 };
}

**Why**: 15-minute access tokens limit breach window. `jwtid` enables per-token revocation.

---

JWT Middleware with Explicit Algorithm Pinning

import { Request, Response, NextFunction } from 'express';

export function requireAuth(req: Request, res: Response, next: NextFunction): void {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    res.status(401).json({ error: 'Missing authorization header' });
    return;
  }

  const token = authHeader.slice(7);
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!, {
      algorithms: ['HS256'], // Explicit — prevents alg:none attack
      issuer: 'api.example.com',
    }) as { sub: string; roles: string[] };

    req.user = { id: payload.sub, roles: payload.roles };
    next();
  } catch (err) {
    if (err instanceof jwt.TokenExpiredError) {
      res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
    } else {
      res.status(401).json({ error: 'Invalid token' });
      // Do NOT leak err.message — reveals token structure to attackers
    }
  }
}

**Why**: Prevents the "algorithm none" attack. Without `algorithms`, `jsonwebtoken` < 9 accepted unsigned tokens.

---

Webhook Signature Verification (Stripe/GitHub pattern)

import { createHmac, timingSafeEqual } from 'crypto';

export function verifyWebhookSignature(
  payload: Buffer,
  signature: string,
  secret: string,
  tolerance = 300 // 5 minutes
): boolean {
  // Stripe format: t=timestamp,v1=signature
  const parts = Object.fromEntries(
    signature.split(',').map((p) => p.split('=') as [string, string])
  );
  const timestamp = parseInt(parts['t'] ?? '0', 10);

  // Reject stale webhooks (replay attack prevention)
  const age = Math.floor(Date.now() / 1000) - timestamp;
  if (age > tolerance) return false;

  const signedPayload = `${timestamp}.${payload.toString('utf-8')}`;
  const expected = createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Timing-safe comparison — prevents timing oracle attacks
  return timingSafeEqual(
    Buffer.from(parts['v1'] ?? '', 'hex'),
    Buffer.from(expected, 'hex')
  );
}

**Why**: `timingSafeEqual` takes constant time. Regular `===` returns early on first mismatch, leaking signature bytes via timing oracle.

---

Pattern Catalog

Always Use jwt.verify() with Algorithm Pinning

**Detection**:

grep -rn 'jwt\.decode(' --include="*.ts" --include="*.js" src/
rg 'jwt\.decode\(' --type ts src/

**Signal**:

// "decode" sounds like "verify" — it isn't
const payload = jwt.decode(req.headers.authorization?.split(' ')[1] ?? '');
if (payload && payload.sub) {
  req.user = { id: payload.sub };
  next();
}

**Why this matters**: `jwt.decode()` does NOT verify the signature. Any attacker can craft a token with any `sub` value. Auth completely bypassed.

**Preferred action**: Always use `jwt.verify()` with `algorithms` specified.

---

Use Constant-Time Auth Responses

**Detection**:

grep -rn 'return.*null\|return.*false' --include="*.ts" src/auth
# Look for early returns before bcrypt.compare
grep -rn 'findUser\|findByEmail' --include="*.ts" src/ -A10 | grep -v 'compare\|bcrypt'

**Signal**:

async function login(email: string, password: string) {
  const user = await db.users.findByEmail(email);
  if (!user) {
    return { error: 'User not found' }; // Responds fast — user doesn't exist
  }
  const valid = await bcrypt.compare(password, user.passwordHash); // Responds slow
  if (!valid) {
    return { error: 'Invalid password' }; // Responds after bcrypt delay
  }
  return { token: issueToken(user.id) };
}

**Why this matters**: User doesn't exist = ~1ms (DB miss). Wrong password = ~100ms (bcrypt). Attackers enumera

Read more
Ships withvexjoy-agent

Essays and writing behind this toolkit live at vexjoy.com. AI agents skip steps. "Looks correct" replaces running tests. "Trivial change" replaces verification.

Get the whole plugin, auto-invoked