Back to blog

Build a URL Shortener: Authentication & User Management

typescriptnodejsauthenticationjwtbackend
Build a URL Shortener: Authentication & User Management

Up to this point, our URL shortener is a free-for-all. Anyone can shorten URLs, and there's no concept of ownership. That's fine for a toy project, but real URL shorteners need to answer: who created this link?

In this post, we'll add user authentication and URL ownership. Users will register, log in, get JWT tokens, and manage their own URLs. We'll also add API keys for programmatic access — because not everyone wants to click buttons in a dashboard.

Time commitment: 2–3 hours
Prerequisites: Phase 5: Caching with Redis

What we'll build in this post:
✅ User registration with email and bcrypt password hashing
✅ Login endpoint that issues JWT access + refresh tokens
✅ Auth middleware to protect routes
✅ User-scoped URL management (list, update, delete my URLs)
✅ API key generation for programmatic access
✅ Token refresh flow for seamless session management


Authentication Architecture

Before writing code, let's understand the token strategy:

Why Access + Refresh Tokens?

A single long-lived token is a security risk — if stolen, an attacker has access for days or weeks. Short-lived tokens are safer but annoying (users must log in every 15 minutes).

The two-token strategy solves this:

TokenLifetimePurposeStored Where
Access token15 minutesAuthenticate API requestsClient memory
Refresh token7 daysGet new access tokensDatabase + client

If an access token is stolen, the damage window is 15 minutes. If a refresh token is stolen, we can revoke it from the database.


Prisma Schema — Users and Refresh Tokens

First, let's extend our Prisma schema with user-related models:

// prisma/schema.prisma
 
model User {
  id          String    @id @default(uuid())
  email       String    @unique
  password    String    // bcrypt hash
  name        String?
  apiKey      String?   @unique @map("api_key")
  isActive    Boolean   @default(true) @map("is_active")
  createdAt   DateTime  @default(now()) @map("created_at")
  updatedAt   DateTime  @updatedAt @map("updated_at")
 
  urls          Url[]
  refreshTokens RefreshToken[]
 
  @@map("users")
}
 
model RefreshToken {
  id        String   @id @default(uuid())
  token     String   @unique
  userId    String   @map("user_id")
  expiresAt DateTime @map("expires_at")
  createdAt DateTime @default(now()) @map("created_at")
 
  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
 
  @@index([token])
  @@index([userId])
  @@map("refresh_tokens")
}

And update the existing Url model to support optional ownership:

model Url {
  id          String    @id @default(uuid())
  shortCode   String    @unique @map("short_code")
  originalUrl String    @map("original_url")
  clickCount  Int       @default(0) @map("click_count")
  userId      String?   @map("user_id")   // null = anonymous
  customAlias Boolean   @default(false) @map("custom_alias")
  expiresAt   DateTime? @map("expires_at")
  createdAt   DateTime  @default(now()) @map("created_at")
  updatedAt   DateTime  @updatedAt @map("updated_at")
 
  user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
 
  @@index([shortCode])
  @@index([userId])
  @@map("urls")
}

Key design decisions:

  • userId is optional — anonymous users can still shorten URLs (backward compatible)
  • RefreshToken table — stores refresh tokens so we can revoke them
  • apiKey on User — single API key per user, generated on demand
  • Cascade delete on refresh tokens — when a user is deleted, all their tokens are deleted too
  • SetNull on URLs — when a user is deleted, their URLs remain but lose ownership

Run the migration:

npx prisma migrate dev --name add-users-and-auth

Install Dependencies

npm install bcrypt jsonwebtoken
npm install -D @types/bcrypt @types/jsonwebtoken
  • bcrypt — industry-standard password hashing with salt rounds
  • jsonwebtoken — JWT creation and verification

Environment Variables

Add to .env:

# Auth
JWT_ACCESS_SECRET=your-access-token-secret-change-in-production
JWT_REFRESH_SECRET=your-refresh-token-secret-change-in-production
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d

Update src/config/env.ts:

// src/config/env.ts
 
import dotenv from 'dotenv';
 
dotenv.config();
 
export const env = {
  port: parseInt(process.env.PORT || '3000', 10),
  baseUrl: process.env.BASE_URL || 'http://localhost:3000',
  nodeEnv: process.env.NODE_ENV || 'development',
  databaseUrl: process.env.DATABASE_URL || '',
  redisUrl: process.env.REDIS_URL || 'redis://localhost:6379',
 
  // Auth
  jwtAccessSecret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret',
  jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret',
  jwtAccessExpiry: process.env.JWT_ACCESS_EXPIRY || '15m',
  jwtRefreshExpiry: process.env.JWT_REFRESH_EXPIRY || '7d',
} as const;

Auth Types

Define the types we'll use across the auth system:

// src/types/auth.ts
 
export interface RegisterRequest {
  email: string;
  password: string;
  name?: string;
}
 
export interface LoginRequest {
  email: string;
  password: string;
}
 
export interface AuthTokens {
  accessToken: string;
  refreshToken: string;
}
 
export interface TokenPayload {
  userId: string;
  email: string;
}
 
export interface AuthenticatedRequest {
  userId: string;
  email: string;
}
 
export interface UserResponse {
  id: string;
  email: string;
  name: string | null;
  createdAt: Date;
}
 
export interface ApiKeyResponse {
  apiKey: string;
}

Auth Service

The auth service handles registration, login, token management, and API keys. This is the core of our authentication system:

// src/services/authService.ts
 
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { PrismaClient } from '@prisma/client';
import { env } from '../config/env';
import { AppError } from './urlService';
import {
  RegisterRequest,
  LoginRequest,
  AuthTokens,
  TokenPayload,
  UserResponse,
} from '../types/auth';
 
const prisma = new PrismaClient();
 
const SALT_ROUNDS = 12;
 
export class AuthService {
  // ─── Registration ──────────────────────────────────────
 
  async register(data: RegisterRequest): Promise<UserResponse> {
    const { email, password, name } = data;
 
    // Check if email already exists
    const existing = await prisma.user.findUnique({
      where: { email: email.toLowerCase() },
    });
 
    if (existing) {
      throw new AppError('Email is already registered', 409);
    }
 
    // Validate password strength
    this.validatePassword(password);
 
    // Hash password
    const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
 
    // Create user
    const user = await prisma.user.create({
      data: {
        email: email.toLowerCase(),
        password: hashedPassword,
        name: name || null,
      },
    });
 
    return this.toUserResponse(user);
  }
 
  // ─── Login ──────────────────────────────────────────────
 
  async login(data: LoginRequest): Promise<AuthTokens> {
    const { email, password } = data;
 
    // Find user
    const user = await prisma.user.findUnique({
      where: { email: email.toLowerCase() },
    });
 
    if (!user) {
      // Use same message for missing user and wrong password
      // to prevent email enumeration
      throw new AppError('Invalid email or password', 401);
    }
 
    if (!user.isActive) {
      throw new AppError('Account is deactivated', 403);
    }
 
    // Verify password
    const isValid = await bcrypt.compare(password, user.password);
 
    if (!isValid) {
      throw new AppError('Invalid email or password', 401);
    }
 
    // Generate tokens
    return this.generateTokens(user.id, user.email);
  }
 
  // ─── Token Management ──────────────────────────────────
 
  async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string }> {
    // Find the refresh token in database
    const storedToken = await prisma.refreshToken.findUnique({
      where: { token: refreshToken },
      include: { user: true },
    });
 
    if (!storedToken) {
      throw new AppError('Invalid refresh token', 401);
    }
 
    // Check if token is expired
    if (storedToken.expiresAt < new Date()) {
      // Clean up expired token
      await prisma.refreshToken.delete({
        where: { id: storedToken.id },
      });
      throw new AppError('Refresh token has expired', 401);
    }
 
    // Verify the JWT signature
    try {
      jwt.verify(refreshToken, env.jwtRefreshSecret);
    } catch {
      await prisma.refreshToken.delete({
        where: { id: storedToken.id },
      });
      throw new AppError('Invalid refresh token', 401);
    }
 
    // Generate new access token
    const accessToken = this.generateAccessToken(
      storedToken.user.id,
      storedToken.user.email
    );
 
    return { accessToken };
  }
 
  async logout(refreshToken: string): Promise<void> {
    await prisma.refreshToken.deleteMany({
      where: { token: refreshToken },
    });
  }
 
  async logoutAllDevices(userId: string): Promise<void> {
    await prisma.refreshToken.deleteMany({
      where: { userId },
    });
  }
 
  // ─── API Keys ──────────────────────────────────────────
 
  async generateApiKey(userId: string): Promise<{ apiKey: string }> {
    const apiKey = `usk_${crypto.randomBytes(32).toString('hex')}`;
 
    await prisma.user.update({
      where: { id: userId },
      data: { apiKey },
    });
 
    return { apiKey };
  }
 
  async revokeApiKey(userId: string): Promise<void> {
    await prisma.user.update({
      where: { id: userId },
      data: { apiKey: null },
    });
  }
 
  async validateApiKey(apiKey: string): Promise<TokenPayload | null> {
    const user = await prisma.user.findUnique({
      where: { apiKey },
    });
 
    if (!user || !user.isActive) {
      return null;
    }
 
    return { userId: user.id, email: user.email };
  }
 
  // ─── User Profile ──────────────────────────────────────
 
  async getProfile(userId: string): Promise<UserResponse> {
    const user = await prisma.user.findUnique({
      where: { id: userId },
    });
 
    if (!user) {
      throw new AppError('User not found', 404);
    }
 
    return this.toUserResponse(user);
  }
 
  // ─── Private Helpers ───────────────────────────────────
 
  private validatePassword(password: string): void {
    if (password.length < 8) {
      throw new AppError('Password must be at least 8 characters', 400);
    }
 
    if (password.length > 128) {
      throw new AppError('Password must be at most 128 characters', 400);
    }
 
    if (!/[A-Z]/.test(password)) {
      throw new AppError('Password must contain at least one uppercase letter', 400);
    }
 
    if (!/[a-z]/.test(password)) {
      throw new AppError('Password must contain at least one lowercase letter', 400);
    }
 
    if (!/[0-9]/.test(password)) {
      throw new AppError('Password must contain at least one number', 400);
    }
  }
 
  private async generateTokens(userId: string, email: string): Promise<AuthTokens> {
    const accessToken = this.generateAccessToken(userId, email);
    const refreshToken = this.generateRefreshToken(userId, email);
 
    // Calculate refresh token expiry
    const expiresAt = new Date();
    expiresAt.setDate(expiresAt.getDate() + 7); // 7 days
 
    // Store refresh token in database
    await prisma.refreshToken.create({
      data: {
        token: refreshToken,
        userId,
        expiresAt,
      },
    });
 
    return { accessToken, refreshToken };
  }
 
  private generateAccessToken(userId: string, email: string): string {
    const payload: TokenPayload = { userId, email };
    return jwt.sign(payload, env.jwtAccessSecret, {
      expiresIn: env.jwtAccessExpiry,
    });
  }
 
  private generateRefreshToken(userId: string, email: string): string {
    const payload: TokenPayload = { userId, email };
    return jwt.sign(payload, env.jwtRefreshSecret, {
      expiresIn: env.jwtRefreshExpiry,
    });
  }
 
  private toUserResponse(user: {
    id: string;
    email: string;
    name: string | null;
    createdAt: Date;
  }): UserResponse {
    return {
      id: user.id,
      email: user.email,
      name: user.name,
      createdAt: user.createdAt,
    };
  }
}

Key Security Decisions

12 salt rounds for bcrypt — each round doubles the hashing time. 12 rounds takes ~250ms, which is slow enough to deter brute-force attacks but fast enough that users don't notice.

Salt RoundsTime per HashPurpose
8~40msToo fast, weak protection
10~100msMinimum for production
12~250msGood balance
14~1sParanoid mode

Same error message for wrong email and wrong password"Invalid email or password" prevents attackers from discovering which emails exist in your system (email enumeration).

API key formatusk_ prefix makes keys identifiable. If a key leaks in a log, you immediately know what it is. The 32-byte hex gives 256 bits of entropy.


Auth Middleware

The middleware extracts and verifies the JWT from the Authorization header, then attaches user info to the request:

// src/middleware/auth.ts
 
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { env } from '../config/env';
import { TokenPayload } from '../types/auth';
import { AuthService } from '../services/authService';
 
const authService = new AuthService();
 
// Extend Express Request to include user info
declare global {
  namespace Express {
    interface Request {
      user?: TokenPayload;
    }
  }
}
 
export function verifyToken(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  // Check for Bearer token
  const authHeader = req.headers.authorization;
 
  // Check for API key
  const apiKey = req.headers['x-api-key'] as string;
 
  if (!authHeader && !apiKey) {
    res.status(401).json({ error: 'Authentication required' });
    return;
  }
 
  // Try API key first
  if (apiKey) {
    authService
      .validateApiKey(apiKey)
      .then((payload) => {
        if (!payload) {
          res.status(401).json({ error: 'Invalid API key' });
          return;
        }
        req.user = payload;
        next();
      })
      .catch(next);
    return;
  }
 
  // Try Bearer token
  if (authHeader) {
    const parts = authHeader.split(' ');
 
    if (parts.length !== 2 || parts[0] !== 'Bearer') {
      res.status(401).json({
        error: 'Invalid authorization format. Use: Bearer <token>',
      });
      return;
    }
 
    const token = parts[1];
 
    try {
      const payload = jwt.verify(token, env.jwtAccessSecret) as TokenPayload;
      req.user = payload;
      next();
    } catch (err) {
      if (err instanceof jwt.TokenExpiredError) {
        res.status(401).json({
          error: 'Access token has expired',
          code: 'TOKEN_EXPIRED',
        });
        return;
      }
 
      res.status(401).json({ error: 'Invalid access token' });
      return;
    }
  }
}
 
// Optional auth — attaches user if token present, but doesn't reject
export function optionalAuth(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const authHeader = req.headers.authorization;
  const apiKey = req.headers['x-api-key'] as string;
 
  if (!authHeader && !apiKey) {
    next();
    return;
  }
 
  // Delegate to verifyToken, but catch 401s and continue
  verifyToken(req, res, (err) => {
    if (err) {
      // Authentication failed, but that's ok — continue without user
      next();
    } else {
      next();
    }
  });
}

Two Authentication Modes

verifyToken — strict. Returns 401 if no valid token. Use on protected routes.

optionalAuth — lenient. Attaches user info if present, continues without it. Use on the URL shortening endpoint so authenticated users get URL ownership while anonymous users still work.

Why Support Both Bearer Token and API Key?

Bearer tokens are for interactive sessions (browser, mobile app). API keys are for automation (CI/CD, scripts, integrations). Supporting both in the same middleware keeps routes clean — the route doesn't care how you authenticated.


Auth Validation

Add request validation for registration and login:

// src/middleware/validateAuth.ts
 
import { Request, Response, NextFunction } from 'express';
 
export function validateRegister(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const { email, password } = req.body;
 
  if (!email || typeof email !== 'string') {
    res.status(400).json({ error: 'Email is required' });
    return;
  }
 
  // Basic email format check
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(email.trim())) {
    res.status(400).json({ error: 'Invalid email format' });
    return;
  }
 
  if (!password || typeof password !== 'string') {
    res.status(400).json({ error: 'Password is required' });
    return;
  }
 
  // Normalize email
  req.body.email = email.trim().toLowerCase();
  next();
}
 
export function validateLogin(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const { email, password } = req.body;
 
  if (!email || typeof email !== 'string') {
    res.status(400).json({ error: 'Email is required' });
    return;
  }
 
  if (!password || typeof password !== 'string') {
    res.status(400).json({ error: 'Password is required' });
    return;
  }
 
  req.body.email = email.trim().toLowerCase();
  next();
}
 
export function validateRefreshToken(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const { refreshToken } = req.body;
 
  if (!refreshToken || typeof refreshToken !== 'string') {
    res.status(400).json({ error: 'Refresh token is required' });
    return;
  }
 
  next();
}

Auth Routes

// src/routes/authRoutes.ts
 
import { Router, Request, Response, NextFunction } from 'express';
import { AuthService } from '../services/authService';
import { verifyToken } from '../middleware/auth';
import {
  validateRegister,
  validateLogin,
  validateRefreshToken,
} from '../middleware/validateAuth';
 
const router = Router();
const authService = new AuthService();
 
// POST /api/auth/register
router.post(
  '/register',
  validateRegister,
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const user = await authService.register(req.body);
      res.status(201).json({
        message: 'Registration successful',
        user,
      });
    } catch (error) {
      next(error);
    }
  }
);
 
// POST /api/auth/login
router.post(
  '/login',
  validateLogin,
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const tokens = await authService.login(req.body);
      res.json({
        message: 'Login successful',
        ...tokens,
      });
    } catch (error) {
      next(error);
    }
  }
);
 
// POST /api/auth/refresh
router.post(
  '/refresh',
  validateRefreshToken,
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const result = await authService.refreshAccessToken(req.body.refreshToken);
      res.json(result);
    } catch (error) {
      next(error);
    }
  }
);
 
// POST /api/auth/logout
router.post(
  '/logout',
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const { refreshToken } = req.body;
      if (refreshToken) {
        await authService.logout(refreshToken);
      }
      res.json({ message: 'Logged out successfully' });
    } catch (error) {
      next(error);
    }
  }
);
 
// POST /api/auth/logout-all — revoke all refresh tokens
router.post(
  '/logout-all',
  verifyToken,
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      await authService.logoutAllDevices(req.user!.userId);
      res.json({ message: 'Logged out from all devices' });
    } catch (error) {
      next(error);
    }
  }
);
 
// GET /api/auth/profile
router.get(
  '/profile',
  verifyToken,
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const profile = await authService.getProfile(req.user!.userId);
      res.json(profile);
    } catch (error) {
      next(error);
    }
  }
);
 
// POST /api/auth/api-key — generate a new API key
router.post(
  '/api-key',
  verifyToken,
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const result = await authService.generateApiKey(req.user!.userId);
      res.status(201).json({
        message: 'API key generated. Store it securely — it cannot be retrieved later.',
        ...result,
      });
    } catch (error) {
      next(error);
    }
  }
);
 
// DELETE /api/auth/api-key — revoke API key
router.delete(
  '/api-key',
  verifyToken,
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      await authService.revokeApiKey(req.user!.userId);
      res.json({ message: 'API key revoked' });
    } catch (error) {
      next(error);
    }
  }
);
 
export default router;

User-Scoped URL Management

Now the important part — letting users manage their own URLs. We need a service that handles URL CRUD operations scoped to the authenticated user:

// src/services/userUrlService.ts
 
import { PrismaClient } from '@prisma/client';
import { env } from '../config/env';
import { AppError } from './urlService';
 
const prisma = new PrismaClient();
 
export interface UserUrl {
  id: string;
  shortCode: string;
  shortUrl: string;
  originalUrl: string;
  clickCount: number;
  customAlias: boolean;
  expiresAt: Date | null;
  createdAt: Date;
}
 
export interface UpdateUrlRequest {
  originalUrl?: string;
  expiresAt?: string | null;
}
 
export interface PaginatedResult<T> {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
}
 
export class UserUrlService {
  // ─── List User's URLs ──────────────────────────────────
 
  async listUrls(
    userId: string,
    page: number = 1,
    pageSize: number = 20,
    search?: string
  ): Promise<PaginatedResult<UserUrl>> {
    const skip = (page - 1) * pageSize;
 
    const where: any = { userId };
 
    if (search) {
      where.OR = [
        { shortCode: { contains: search, mode: 'insensitive' } },
        { originalUrl: { contains: search, mode: 'insensitive' } },
      ];
    }
 
    const [urls, total] = await Promise.all([
      prisma.url.findMany({
        where,
        orderBy: { createdAt: 'desc' },
        skip,
        take: pageSize,
      }),
      prisma.url.count({ where }),
    ]);
 
    return {
      data: urls.map(this.toUserUrl),
      total,
      page,
      pageSize,
      totalPages: Math.ceil(total / pageSize),
    };
  }
 
  // ─── Get Single URL ────────────────────────────────────
 
  async getUrl(shortCode: string, userId: string): Promise<UserUrl> {
    const url = await prisma.url.findFirst({
      where: { shortCode, userId },
    });
 
    if (!url) {
      throw new AppError('URL not found or does not belong to you', 404);
    }
 
    return this.toUserUrl(url);
  }
 
  // ─── Update URL ────────────────────────────────────────
 
  async updateUrl(
    shortCode: string,
    userId: string,
    data: UpdateUrlRequest
  ): Promise<UserUrl> {
    // Verify ownership
    const existing = await prisma.url.findFirst({
      where: { shortCode, userId },
    });
 
    if (!existing) {
      throw new AppError('URL not found or does not belong to you', 404);
    }
 
    // Validate new URL if provided
    if (data.originalUrl) {
      try {
        const parsed = new URL(data.originalUrl);
        if (!['http:', 'https:'].includes(parsed.protocol)) {
          throw new AppError('Only HTTP and HTTPS URLs are supported', 400);
        }
      } catch (err) {
        if (err instanceof AppError) throw err;
        throw new AppError('Invalid URL format', 400);
      }
    }
 
    // Build update data
    const updateData: any = {};
    if (data.originalUrl) {
      updateData.originalUrl = data.originalUrl;
    }
    if (data.expiresAt !== undefined) {
      updateData.expiresAt = data.expiresAt ? new Date(data.expiresAt) : null;
    }
 
    const updated = await prisma.url.update({
      where: { id: existing.id },
      data: updateData,
    });
 
    return this.toUserUrl(updated);
  }
 
  // ─── Delete URL ────────────────────────────────────────
 
  async deleteUrl(shortCode: string, userId: string): Promise<void> {
    const existing = await prisma.url.findFirst({
      where: { shortCode, userId },
    });
 
    if (!existing) {
      throw new AppError('URL not found or does not belong to you', 404);
    }
 
    await prisma.url.delete({
      where: { id: existing.id },
    });
  }
 
  // ─── URL Stats ─────────────────────────────────────────
 
  async getUrlStats(userId: string): Promise<{
    totalUrls: number;
    totalClicks: number;
    urlsThisMonth: number;
  }> {
    const now = new Date();
    const firstOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
 
    const [totalUrls, clickAgg, urlsThisMonth] = await Promise.all([
      prisma.url.count({ where: { userId } }),
      prisma.url.aggregate({
        where: { userId },
        _sum: { clickCount: true },
      }),
      prisma.url.count({
        where: {
          userId,
          createdAt: { gte: firstOfMonth },
        },
      }),
    ]);
 
    return {
      totalUrls,
      totalClicks: clickAgg._sum.clickCount || 0,
      urlsThisMonth,
    };
  }
 
  // ─── Private Helpers ───────────────────────────────────
 
  private toUserUrl(url: any): UserUrl {
    return {
      id: url.id,
      shortCode: url.shortCode,
      shortUrl: `${env.baseUrl}/${url.shortCode}`,
      originalUrl: url.originalUrl,
      clickCount: url.clickCount,
      customAlias: url.customAlias,
      expiresAt: url.expiresAt,
      createdAt: url.createdAt,
    };
  }
}

Ownership Checks

Every operation checks { shortCode, userId } — users can only see and modify their own URLs. This is a fundamental security principle called row-level access control. Without it, any authenticated user could delete anyone else's URLs.


User URL Routes

// src/routes/userUrlRoutes.ts
 
import { Router, Request, Response, NextFunction } from 'express';
import { UserUrlService } from '../services/userUrlService';
import { verifyToken } from '../middleware/auth';
 
const router = Router();
const userUrlService = new UserUrlService();
 
// All routes require authentication
router.use(verifyToken);
 
// GET /api/my/urls — list my URLs with pagination
router.get(
  '/urls',
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const page = parseInt(req.query.page as string) || 1;
      const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
      const search = req.query.search as string | undefined;
 
      const result = await userUrlService.listUrls(
        req.user!.userId,
        page,
        pageSize,
        search
      );
      res.json(result);
    } catch (error) {
      next(error);
    }
  }
);
 
// GET /api/my/urls/:code — get one of my URLs
router.get(
  '/urls/:code',
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const url = await userUrlService.getUrl(
        req.params.code,
        req.user!.userId
      );
      res.json(url);
    } catch (error) {
      next(error);
    }
  }
);
 
// PATCH /api/my/urls/:code — update one of my URLs
router.patch(
  '/urls/:code',
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const url = await userUrlService.updateUrl(
        req.params.code,
        req.user!.userId,
        req.body
      );
      res.json(url);
    } catch (error) {
      next(error);
    }
  }
);
 
// DELETE /api/my/urls/:code — delete one of my URLs
router.delete(
  '/urls/:code',
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      await userUrlService.deleteUrl(req.params.code, req.user!.userId);
      res.status(204).send();
    } catch (error) {
      next(error);
    }
  }
);
 
// GET /api/my/stats — get my URL stats
router.get(
  '/stats',
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const stats = await userUrlService.getUrlStats(req.user!.userId);
      res.json(stats);
    } catch (error) {
      next(error);
    }
  }
);
 
export default router;

Update URL Shortening for Ownership

Now we need to update the existing URL shortening endpoint to attach the user ID when an authenticated user creates a link. Update UrlService:

// In src/services/urlService.ts — update the shortenUrl method
 
async shortenUrl(
  originalUrl: string,
  customAlias?: string,
  userId?: string    // NEW: optional user ID
): Promise<CreateUrlResponse> {
  let shortCode: string;
 
  if (customAlias) {
    if (!isValidCustomAlias(customAlias)) {
      throw new AppError(
        'Invalid custom alias. Use 3-30 alphanumeric characters, hyphens, or underscores.',
        400
      );
    }
 
    const existingCode = await prisma.url.findUnique({
      where: { shortCode: customAlias },
    });
 
    if (existingCode) {
      throw new AppError('Custom alias is already taken', 409);
    }
 
    shortCode = customAlias;
  } else {
    shortCode = await this.generateUniqueCode();
  }
 
  const record = await prisma.url.create({
    data: {
      shortCode,
      originalUrl,
      userId: userId || null,     // Attach user if authenticated
      customAlias: !!customAlias,
    },
  });
 
  return this.toCreateResponse(record.shortCode, record.originalUrl, record.createdAt);
}

And update the route to pass the user ID:

// In src/routes/urlRoutes.ts — update the POST /shorten handler
 
import { optionalAuth } from '../middleware/auth';
 
router.post(
  '/shorten',
  optionalAuth,          // Use optional auth
  validateShortenRequest,
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const { url, customAlias } = req.body;
      const result = await urlService.shortenUrl(
        url,
        customAlias,
        req.user?.userId    // Pass user ID if authenticated
      );
      res.status(201).json(result);
    } catch (error) {
      next(error);
    }
  }
);

This is backward compatible — anonymous users can still shorten URLs. But authenticated users get their URLs linked to their account.


Wire Routes into Express App

Update app.ts to mount the new auth and user URL routes:

// src/app.ts
 
import express from 'express';
import urlRoutes from './routes/urlRoutes';
import redirectRoutes from './routes/redirectRoutes';
import authRoutes from './routes/authRoutes';
import userUrlRoutes from './routes/userUrlRoutes';
import { errorHandler } from './middleware/errorHandler';
 
const app = express();
 
app.use(express.json({ limit: '10kb' }));
 
// Health check
app.get('/health', (_req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
 
// API routes
app.use('/api/auth', authRoutes);
app.use('/api/my', userUrlRoutes);
app.use('/api', urlRoutes);
 
// Redirect catch-all (must be last)
app.use(redirectRoutes);
 
// Error handler
app.use(errorHandler);
 
export default app;

Route mounting order:

  1. /api/auth/* — authentication endpoints
  2. /api/my/* — user-scoped URL management (requires auth)
  3. /api/* — public URL shortening endpoints
  4. /:code — redirect catch-all

Security Best Practices

Password Storage

Never store plaintext passwords. Always hash with bcrypt and sufficient salt rounds:

// WRONG — storing plaintext
await prisma.user.create({
  data: { email, password: plaintext },
});
 
// CORRECT — hash first
const hashed = await bcrypt.hash(plaintext, 12);
await prisma.user.create({
  data: { email, password: hashed },
});

Token Storage (Client Side)

StorageAccess TokenRefresh Token
localStorageVulnerable to XSSNever store here
sessionStorageLost on tab closeNever store here
Memory (variable)Best for access tokensNot persistent
HttpOnly cookieGood alternativeBest for refresh tokens

For our API-based approach, clients store the access token in memory and the refresh token in an HttpOnly cookie or secure storage.

Rate Limiting Auth Endpoints

Auth endpoints are prime targets for brute-force attacks. Add stricter rate limiting:

// src/middleware/authRateLimit.ts
 
import { Request, Response, NextFunction } from 'express';
 
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
 
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
 
export function loginRateLimit(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const key = req.ip || 'unknown';
  const now = Date.now();
 
  const record = loginAttempts.get(key);
 
  if (record) {
    if (now > record.resetAt) {
      // Window expired, reset
      loginAttempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
      next();
      return;
    }
 
    if (record.count >= MAX_ATTEMPTS) {
      const retryAfter = Math.ceil((record.resetAt - now) / 1000);
      res.status(429).json({
        error: 'Too many login attempts',
        retryAfter,
      });
      return;
    }
 
    record.count += 1;
  } else {
    loginAttempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
  }
 
  next();
}

Apply it to the login route:

import { loginRateLimit } from '../middleware/authRateLimit';
 
router.post('/login', loginRateLimit, validateLogin, async (req, res, next) => {
  // ...
});

5 login attempts per 15 minutes per IP. After that, the user must wait. In production, use Redis for this instead of an in-memory Map (see Phase 5).

Token Cleanup

Expired refresh tokens pile up. Add a cleanup function:

// In authService.ts
 
async cleanupExpiredTokens(): Promise<number> {
  const result = await prisma.refreshToken.deleteMany({
    where: {
      expiresAt: { lt: new Date() },
    },
  });
 
  return result.count;
}

Run this on a schedule (cron job) or as part of the refresh flow.


Updated Folder Structure

url-shortener/
├── src/
│   ├── config/
│   │   └── env.ts                # + JWT config
│   ├── middleware/
│   │   ├── auth.ts               # NEW: JWT + API key verification
│   │   ├── authRateLimit.ts      # NEW: login rate limiting
│   │   ├── errorHandler.ts
│   │   ├── validateAuth.ts       # NEW: auth request validation
│   │   └── validateRequest.ts
│   ├── routes/
│   │   ├── authRoutes.ts         # NEW: register, login, refresh
│   │   ├── redirectRoutes.ts
│   │   ├── urlRoutes.ts          # UPDATED: optional auth
│   │   └── userUrlRoutes.ts      # NEW: user-scoped URL CRUD
│   ├── services/
│   │   ├── authService.ts        # NEW: auth business logic
│   │   ├── urlService.ts         # UPDATED: userId parameter
│   │   └── userUrlService.ts     # NEW: user URL management
│   ├── types/
│   │   ├── auth.ts               # NEW: auth types
│   │   └── url.ts
│   ├── app.ts                    # UPDATED: new route mounts
│   └── index.ts
├── prisma/
│   └── schema.prisma             # UPDATED: User + RefreshToken models
├── .env
└── package.json

Testing the Auth Flow

Start the dev server and test with curl:

Test 1: Register a User

curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "email": "alice@example.com",
    "password": "SecurePass123",
    "name": "Alice"
  }'
{
  "message": "Registration successful",
  "user": {
    "id": "a1b2c3d4-...",
    "email": "alice@example.com",
    "name": "Alice",
    "createdAt": "2026-03-21T10:00:00.000Z"
  }
}

Test 2: Login

curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "alice@example.com",
    "password": "SecurePass123"
  }'
{
  "message": "Login successful",
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}

Save the access token for subsequent requests:

export TOKEN="eyJhbGciOiJIUzI1NiIs..."

Test 3: Shorten a URL (Authenticated)

curl -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"url": "https://github.com/expressjs/express"}'
{
  "shortCode": "aBc1X9z",
  "shortUrl": "http://localhost:3000/aBc1X9z",
  "originalUrl": "https://github.com/expressjs/express",
  "createdAt": "2026-03-21T10:01:00.000Z"
}

This URL is now linked to Alice's account.

Test 4: List My URLs

curl http://localhost:3000/api/my/urls \
  -H "Authorization: Bearer $TOKEN"
{
  "data": [
    {
      "id": "...",
      "shortCode": "aBc1X9z",
      "shortUrl": "http://localhost:3000/aBc1X9z",
      "originalUrl": "https://github.com/expressjs/express",
      "clickCount": 0,
      "customAlias": false,
      "expiresAt": null,
      "createdAt": "2026-03-21T10:01:00.000Z"
    }
  ],
  "total": 1,
  "page": 1,
  "pageSize": 20,
  "totalPages": 1
}

Test 5: Update a URL

curl -X PATCH http://localhost:3000/api/my/urls/aBc1X9z \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"originalUrl": "https://github.com/expressjs/express/releases"}'
{
  "id": "...",
  "shortCode": "aBc1X9z",
  "shortUrl": "http://localhost:3000/aBc1X9z",
  "originalUrl": "https://github.com/expressjs/express/releases",
  "clickCount": 0,
  "customAlias": false,
  "expiresAt": null,
  "createdAt": "2026-03-21T10:01:00.000Z"
}

Test 6: Delete a URL

curl -X DELETE http://localhost:3000/api/my/urls/aBc1X9z \
  -H "Authorization: Bearer $TOKEN" \
  -v
< HTTP/1.1 204 No Content

Test 7: Generate API Key

curl -X POST http://localhost:3000/api/auth/api-key \
  -H "Authorization: Bearer $TOKEN"
{
  "message": "API key generated. Store it securely — it cannot be retrieved later.",
  "apiKey": "usk_a1b2c3d4e5f6..."
}

Test 8: Use API Key

export API_KEY="usk_a1b2c3d4e5f6..."
 
curl -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -H "x-api-key: $API_KEY" \
  -d '{"url": "https://nodejs.org"}'
{
  "shortCode": "xY7mN2q",
  "shortUrl": "http://localhost:3000/xY7mN2q",
  "originalUrl": "https://nodejs.org",
  "createdAt": "2026-03-21T10:05:00.000Z"
}

Test 9: Refresh Token

curl -X POST http://localhost:3000/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "eyJhbGciOiJIUzI1NiIs..."}'
{
  "accessToken": "eyJhbGciOiJIUzI1NiIs..."
}

Test 10: Error Cases

Duplicate email:

curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "alice@example.com", "password": "AnotherPass123"}'
{ "error": "Email is already registered" }

Weak password:

curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "bob@example.com", "password": "weak"}'
{ "error": "Password must be at least 8 characters" }

Expired token:

{
  "error": "Access token has expired",
  "code": "TOKEN_EXPIRED"
}

The code: "TOKEN_EXPIRED" lets the client know it should try the refresh flow instead of asking the user to log in again.


API Summary

Here's the complete API after this post:

MethodEndpointAuthDescription
POST/api/auth/registerNoCreate account
POST/api/auth/loginNoGet tokens
POST/api/auth/refreshNoRefresh access token
POST/api/auth/logoutNoRevoke refresh token
POST/api/auth/logout-allYesRevoke all refresh tokens
GET/api/auth/profileYesGet user profile
POST/api/auth/api-keyYesGenerate API key
DELETE/api/auth/api-keyYesRevoke API key
POST/api/shortenOptionalShorten a URL
GET/api/urls/:codeNoGet URL stats
GET/api/my/urlsYesList my URLs
GET/api/my/urls/:codeYesGet one of my URLs
PATCH/api/my/urls/:codeYesUpdate my URL
DELETE/api/my/urls/:codeYesDelete my URL
GET/api/my/statsYesMy URL statistics
GET/:codeNoRedirect
GET/healthNoHealth check

Common Mistakes to Avoid

1. Storing JWT Secrets in Code

// WRONG — secret in source code
const secret = 'my-super-secret-key';
 
// CORRECT — load from environment
const secret = process.env.JWT_ACCESS_SECRET;

2. Not Checking Token Ownership on Update/Delete

// WRONG — anyone can delete any URL
const url = await prisma.url.findUnique({ where: { shortCode } });
await prisma.url.delete({ where: { id: url.id } });
 
// CORRECT — verify ownership
const url = await prisma.url.findFirst({
  where: { shortCode, userId },
});
if (!url) throw new AppError('Not found or not yours', 404);
await prisma.url.delete({ where: { id: url.id } });

3. Leaking Password Hashes in API Responses

// WRONG — sends everything, including password hash
res.json(user);
 
// CORRECT — map to safe response
res.json({
  id: user.id,
  email: user.email,
  name: user.name,
  createdAt: user.createdAt,
});

4. Using the Same Secret for Access and Refresh Tokens

# WRONG — same secret for both
JWT_SECRET=one-secret-for-everything
 
# CORRECT — separate secrets
JWT_ACCESS_SECRET=access-secret-here
JWT_REFRESH_SECRET=different-refresh-secret

If both tokens share a secret, a leaked access token could be used as a refresh token (or vice versa), defeating the whole purpose of the two-token strategy.


What's Next?

We now have user authentication, URL ownership, and API keys. In Phase 7, we'll build a React frontend:

  • Vite + React + TypeScript project setup
  • Login and registration pages
  • URL shortening form with results display
  • Dashboard with paginated URL list
  • Click stats visualization
  • API key management UI

Our API is ready — now we need a face for it.

Series: Build a URL Shortener
Previous: Phase 5: Caching with Redis
Next: Phase 7: Frontend with React

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.