Skip to Content
Part 1: FoundationsCh 2: Authentication

Authentication answers one question: “Who is making this request?”

Every time your browser sends a request to /api/alerts or /api/ranking/calculate, the server needs to know which user is asking. Without this, User A could see User B’s deals. The entire multi-tenant system collapses.

Astrelo uses JSON Web Tokens (JWTs) for authentication. Let’s understand what that means from the ground up.

What Is a JWT?

A JWT is a signed string that contains information about a user. Think of it like a government-issued ID card:

  • The data (your name, photo, birthdate) = the JWT “payload”
  • The holographic seal (proves the government issued it) = the JWT “signature”
  • The expiration date = the JWT exp claim

Here’s what an Astrelo JWT looks like when decoded:

{ "userId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "email": "sarah@cloudreach.io", "iat": 1711234567, "exp": 1711839367 }
  • userId and email — the user’s identity
  • iat (issued at) — when the token was created (Unix timestamp)
  • exp (expires) — when it stops being valid (7 days later)

The critical property: the server can verify a JWT without a database lookup. The signature is created using a secret key that only the server knows. When a JWT comes back in a request, the server checks the signature against the same secret. If it matches, the token is authentic. If anyone tampered with the payload (changed the userId), the signature wouldn’t match.

How Login Works

Let’s trace a complete login flow.

Step 1: The User Submits Credentials

The login form sends a POST request:

POST /api/auth/login Body: { "email": "sarah@cloudreach.io", "password": "mypassword" }

Step 2: Input Validation with Zod

// src/pages/api/auth/login.ts, lines 94-97 const loginSchema = z.object({ email: z.string().email(), password: z.string() }); const validated = loginSchema.parse(req.body);

zod is a schema validation library. loginSchema.parse() does two things:

  1. Checks that email is a valid email format and password is a string
  2. If invalid, throws a ZodError that gets caught on line 159 and returned as a 400 Bad Request

Why validate? Never trust user input. Someone could send { "email": 123, "password": null } or worse. Validation is the first line of defense.

Step 3: Credential Check

// src/pages/api/auth/login.ts, line 107 const user = await userService.validateCredentials(validated.email, validated.password);

validateCredentials does this internally:

  1. Queries the database: SELECT * FROM users WHERE email = $1
  2. If no user found → returns null
  3. If found, compares the submitted password against the stored hash using bcrypt.compare()

Password hashing explained: The database never stores your actual password. It stores a hash — a one-way mathematical transformation:

"mypassword" → bcrypt → "$2a$10$xK3v8jH.q9nR..."

You cannot reverse a hash to get the original password. But you CAN hash the submitted password and check if it produces the same hash. This way, even if the database is stolen, the attacker gets hashes, not passwords.

bcrypt specifically is designed to be slow on purpose (about 100ms per comparison). This makes brute-force attacks (trying millions of passwords) impractically slow.

Step 4: Token Generation

// src/pages/api/auth/login.ts, lines 113-116 const token = generateToken({ userId: user.id, email: user.email });

Which calls:

// src/infrastructure/auth/jwt.ts, lines 12-14 export function generateToken(payload: JWTPayload): string { return jwt.sign(payload, JWT_SECRET, { expiresIn: '7d' }); }

jwt.sign() takes three arguments:

  1. Payload — the data to embed (userId, email)
  2. Secret — a long random string stored in environment variables (never in code)
  3. Options — expiresIn: '7d' means the token self-destructs after 7 days

The output is a string like: eyJhbGciOiJIUzI1NiIs... (Base64-encoded, three parts separated by dots).

Step 5: Dual Storage

// src/pages/api/auth/login.ts, lines 120-122 const isProduction = process.env.NODE_ENV === 'production'; const secureflag = isProduction ? 'Secure; ' : ''; res.setHeader('Set-Cookie', `auth_token=${token}; HttpOnly; ${secureflag}SameSite=Lax; Path=/; Max-Age=604800` );

The token is stored in two places:

  1. In the JSON response — the frontend stores this in localStorage and attaches it to every API request as Authorization: Bearer <token>
  2. In an HTTP-only cookie — the browser automatically sends this with every request. This is needed for OAuth callback flows (when HubSpot/Salesforce redirect back to your app, there’s no JavaScript running to attach the header)

The cookie flags matter:

  • HttpOnly — JavaScript cannot read this cookie (prevents XSS attacks from stealing tokens)
  • Secure — only sent over HTTPS (production only, disabled for localhost)
  • SameSite=Lax — prevents the cookie from being sent in cross-site requests (CSRF protection)
  • Max-Age=604800 — expires in 7 days (604,800 seconds), matching the JWT expiry

Step 6: Background Scoring (Fire-and-Forget)

// src/pages/api/auth/login.ts, lines 126-129 const orchestrator = createScoringOrchestrator(pool); orchestrator.runScoringIfStale(user.id) .then(() => {}) .catch(() => {});

Notice the .then(() => {}).catch(() => {}) — this is the fire-and-forget pattern. The login response is returned immediately (line 145). The scoring runs in the background. If it fails, it fails silently — the user doesn’t need to wait for ML scoring to finish before they can use the app.

This is a conscious design choice: login must be fast. Scoring can take 30-60 seconds for 500 companies. Making the user stare at a spinner for a minute on every login would be a terrible experience.

The Middleware: requireAuth

Now let’s understand the function that protects every API route in the application.

// src/infrastructure/auth/middleware.ts export interface AuthenticatedRequest extends NextApiRequest { userId?: string; userEmail?: string; isAdmin?: boolean; scopes?: string[]; apiKeyId?: string; } export function requireAuth( handler: (req: AuthenticatedRequest, res: NextApiResponse) => Promise<void> | void ) { return async (req: AuthenticatedRequest, res: NextApiResponse) => { // Path 1: Try JWT const token = getTokenFromRequest(req); if (token) { const payload = verifyToken(token); if (payload) { req.userId = payload.userId; req.userEmail = payload.email; return await handler(req, res); } return res.status(401).json({ error: 'Invalid or expired token' }); } // Path 2: Try API key const apiKey = req.headers['x-api-key'] as string | undefined; if (apiKey) { // ... API key validation with rate limiting and quotas } // Path 3: No credentials return res.status(401).json({ error: 'Authentication required' }); }; }

Higher-Order Functions

requireAuth is a higher-order function — a function that takes a function as input and returns a new function as output. This is one of the most powerful patterns in JavaScript.

Without requireAuth, every API route would need this:

// BAD — duplicated auth logic in every route export default async function handler(req, res) { const token = getTokenFromRequest(req); if (!token) return res.status(401).json({ error: 'Auth required' }); const payload = verifyToken(token); if (!payload) return res.status(401).json({ error: 'Invalid token' }); const userId = payload.userId; // ... actual route logic }

With requireAuth, you write the auth logic once and wrap every route:

// GOOD — auth handled by middleware async function handler(req: AuthenticatedRequest, res: NextApiResponse) { const userId = req.userId!; // Guaranteed to exist // ... actual route logic } export default requireAuth(handler);

68 routes, one auth implementation. If you need to change how auth works (add a new token type, change expiry logic), you change one file.

The ! Non-Null Assertion

You’ll see req.userId! everywhere. The ! tells TypeScript: “I know this value is not null/undefined, even though the type says it could be.”

Why is it safe? Because requireAuth guarantees that if handler is called, userId has been set. The middleware either attaches userId and calls handler, or returns a 401. There is no code path where handler runs with userId undefined.

Two Authentication Paths

The middleware supports two ways to authenticate:

  1. JWT (Bearer token or cookie) — used by the browser-based app. Every logged-in user has a JWT.
  2. API Key (X-API-Key header) — used by external integrations. API keys have scopes (read, write) and rate limits.

For API key auth, there are extra protections:

  • Rate limiting (line 66-70): A sliding window that caps requests per key per time period
  • Quota checking (line 74-82): Monthly limits on API calls, enrichment operations, and discovery runs
  • Usage logging (line 106): Every API key request is logged with endpoint, method, status code, and response time

requireAdmin: Composition in Action

// src/infrastructure/auth/middleware.ts, lines 136-149 export function requireAdmin(handler) { return requireAuth(async (req, res) => { const isAdmin = await checkIsAdmin(req.userId!); if (!isAdmin) { return res.status(403).json({ error: 'Admin access required' }); } req.isAdmin = true; return handler(req, res); }); }

requireAdmin doesn’t rewrite auth logic. It composes on top of requireAuth:

  1. requireAuth runs first — checks JWT, attaches userId
  2. Then the admin check runs — queries SELECT is_admin FROM users WHERE id = $1
  3. If both pass, the actual handler runs

This is function composition: requireAdmin = requireAuth + admin check + handler. Each piece does one thing.

Token Verification: What Happens on Every Request

// src/infrastructure/auth/jwt.ts, lines 16-23 export function verifyToken(token: string): JWTPayload | null { try { const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload; return decoded; } catch (error) { return null; } }

jwt.verify() does several things internally:

  1. Splits the token into three parts (header, payload, signature)
  2. Recomputes the signature using the payload + secret
  3. Compares the computed signature with the one in the token
  4. Checks that exp hasn’t passed (the token isn’t expired)
  5. If everything checks out, returns the decoded payload

If ANY step fails (tampered token, wrong secret, expired), it throws an error. The catch returns null, and the middleware returns 401.

This is stateless authentication. The server doesn’t need to look up a session in the database. The JWT contains everything needed to identify the user. This is why JWTs are popular for APIs — each request is self-contained.

The tradeoff: you can’t easily “revoke” a JWT. If a user’s token is stolen, it’s valid until it expires (7 days). Compare this with session-based auth, where the server can delete the session from the database instantly. Astrelo accepts this tradeoff because the 7-day window is reasonable for a B2B SaaS, and the simplicity of stateless auth at 68 endpoints is worth it.

Token Extraction: Where Does the Token Come From?

// src/infrastructure/auth/jwt.ts, lines 25-38 export function getTokenFromRequest(req: any): string | null { // First, try Authorization header const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith('Bearer ')) { return authHeader.substring(7); } // Fallback to cookie if (req.cookies && req.cookies.auth_token) { return req.cookies.auth_token; } return null; }

Two sources, in priority order:

  1. Authorization: Bearer <token> — the standard way. The frontend’s React Query hooks attach this header to every fetch request.
  2. auth_token cookie — the fallback. When the user returns from an OAuth flow (HubSpot redirects back to your app), there’s no JavaScript running to set headers. The browser automatically sends cookies, so the API can still identify the user.

The Auth Context: Frontend Side

The frontend counterpart lives in src/contexts/AuthContext.tsx. It manages the user’s session state:

// Simplified from AuthContext.tsx const AuthContext = createContext({ user: null, token: null, login, logout }); function AuthProvider({ children }) { const [user, setUser] = useState(null); const [token, setToken] = useState(null); // On mount: check localStorage for existing token useEffect(() => { const stored = localStorage.getItem('auth_token'); if (stored) { // Verify it's still valid by calling /api/auth/me fetch('/api/auth/me', { headers: { Authorization: `Bearer ${stored}` } }) .then(res => res.json()) .then(data => { setUser(data.user); setToken(stored); }) .catch(() => { localStorage.removeItem('auth_token'); }); } }, []); return <AuthContext.Provider value={{ user, token, login, logout }}>{children}</AuthContext.Provider>; }

The initialization flow:

  1. Check localStorage for a previously stored token
  2. If found, call GET /api/auth/me to verify it’s still valid and get the user object
  3. If valid → set user state (app renders normally)
  4. If invalid → clear localStorage (user sees the login page)

Every hook in the app can access the auth state:

const { token } = useAuth(); // Get the JWT for API calls const { user } = useAuth(); // Get user info (name, email, is_admin)

Key Takeaways

  1. Passwords are never stored in plain text. bcrypt hashes them with intentional slowness to resist brute-force attacks.
  2. JWTs are signed, not encrypted. Anyone can decode the payload (it’s Base64). The signature proves the server created it and it hasn’t been tampered with.
  3. requireAuth is a higher-order function that wraps every API route with authentication. This is the middleware pattern — write it once, apply it everywhere.
  4. Stateless auth means no database lookup on every request. The JWT contains the user’s identity. The tradeoff is that tokens can’t be instantly revoked.
  5. Dual storage (localStorage + cookie) handles both programmatic API calls and browser-redirect OAuth flows.

Next chapter: we’ll look at the database — 60 tables, how they relate, how migrations work, and the SQL patterns used throughout the application.

Last updated on