Skip to content

Env, config & CORS ​

Secrets aren't VITE_-prefixed. CORS is why fetch from localhost:5173 to localhost:3000 fails until you allow it.

.env on the server ​

bash
# .env - never commit
PORT=3000
JWT_SECRET=change-me-to-a-long-random-string
DATABASE_PATH=./data.sqlite
CORS_ORIGIN=http://localhost:5173
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASS=
MAIL_FROM="Notes API <noreply@example.com>"
APP_URL=http://localhost:5173
bash
# .env.example - commit this
PORT=3000
JWT_SECRET=dev-only-change-me
DATABASE_PATH=./data.sqlite
CORS_ORIGIN=http://localhost:5173
# …

Load at boot:

bash
npm install dotenv
ts
import 'dotenv/config'
// or: import dotenv from 'dotenv'; dotenv.config()

const port = Number(process.env.PORT ?? 3000)
const jwtSecret = process.env.JWT_SECRET
if (!jwtSecret) throw new Error('JWT_SECRET missing')

Fail fast on missing secrets. Silent undefined JWT secrets are a security horror movie.

FE vs server env (important) ​

Vite SPAExpress
import.meta.env.VITE_API_URLprocess.env.PORT
Anything VITE_ is public in the bundleSecrets stay on the server
Never put JWT_SECRET in ViteSign tokens only in Express

Your SPA only needs the API base URL. Tokens live in memory / httpOnly cookies / localStorage debates - we use Authorization: Bearer for this tutorial.

CORS without the mythology ​

Browser rule: JS on origin A cannot read responses from origin B unless B opts in.

Vite http://localhost:5173 + API http://localhost:3000 = two origins.

bash
npm install cors
npm install -D @types/cors
ts
import cors from 'cors'

app.use(
  cors({
    origin: process.env.CORS_ORIGIN ?? 'http://localhost:5173',
    credentials: false, // true later if you use cookie sessions
  }),
)

Comments for the code above:

  • origin can be a string or a whitelist function
  • Preflight OPTIONS is handled by cors for you
  • credentials: true needs matching fetch(..., { credentials: 'include' }) and a specific origin (not *)

curl doesn't care about CORS. Your SPA does. If Postman works but the browser doesn't - it's CORS (or mixed content), not your route logic.

Config module pattern ​

ts
// src/config.ts
function required(name: string): string {
  const v = process.env[name]
  if (!v) throw new Error(`Missing env ${name}`)
  return v
}

export const config = {
  port: Number(process.env.PORT ?? 3000),
  jwtSecret: required('JWT_SECRET'),
  corsOrigin: process.env.CORS_ORIGIN ?? 'http://localhost:5173',
  databasePath: process.env.DATABASE_PATH ?? './data.sqlite',
  appUrl: process.env.APP_URL ?? 'http://localhost:5173',
}

Import config instead of sprinkling process.env everywhere.

Checklist before the auth arc ​

  • [ ] .env exists locally, listed in .gitignore
  • [ ] JWT_SECRET is long and not "secret"
  • [ ] CORS allows your Vite origin
  • [ ] SPA points at http://localhost:3000 (or whatever PORT is)

Next we store real data - SQLite as a friendly "collection on disk".

Best,

-- ł.

Notes-style Express tutorial for front-end folks