Authentication
Authentication answers one question on every request: who is calling?
The classic flow — and the one this guide builds — is: clients start by
authenticating with an email and password; the server verifies the
credentials and issues a JWT (JSON Web Token); subsequent requests
send that token as a bearer token in the Authorization header, and a
middleware rejects any request without a valid one.
Two sibling projects cover the crypto so the application code stays small:
luarocks install bcryptlua # password hashingluarocks install jsonwebtoken # signing and verifying JWTsbcryptlua is covered in the hashing guide; jsonwebtoken signs, verifies and decodes tokens in pure Lua — no OpenSSL, nothing to compile.
What a JWT is
Section titled “What a JWT is”A token is three base64url parts — header, claims, signature — signed with a secret only the server knows:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsImV4cCI6...}.SflKxw...The claims are plain data about the caller (sub — the subject,
exp — the expiry). The signature is what makes them trustworthy:
a client can read its token, but any change breaks the signature, and
only the server can produce a valid one.
The sign-in endpoint
Section titled “The sign-in endpoint”Verify the credentials with bcrypt, then issue a token. The claims are
signed exactly as given — set exp yourself, or the token never
expires:
local ludi = require("ludi")local bcrypt = require("bcryptlua")local jwt = require("jsonwebtoken")
local SECRET = os.getenv("JWT_SECRET")
local app = ludi.new()
app:post("/auth/login", function(req, res) local body = req:json() local user = find_user(body.email)
if not user or not bcrypt.verify(body.password, user.password_hash) then res:status(401):json({ error = "Invalid credentials" }) return end
local token = jwt.sign({ sub = tostring(user.id), exp = os.time() + 3600, -- valid for one hour }, SECRET)
res:json({ access_token = token })end)Trying it out:
curl -X POST http://localhost:3000/auth/login \ -H "Content-Type: application/json" \ -d '{"email": "maria@example.com", "password": "guess"}'# {"access_token":"eyJhbGciOiJIUzI1NiIs..."}The auth middleware
Section titled “The auth middleware”Protecting routes is a job for a
middleware: read the bearer token, verify it,
and attach the claims to the request. Not calling next() is what
rejects the request:
local function auth(req, res, next) local header = req.headers["authorization"] or "" local token = header:match("^Bearer (.+)$")
local claims = token and jwt.verify(token, SECRET) if not claims then res:status(401):json({ error = "Unauthorized" }) return end
req.user = claims next()endjwt.verify checks the signature and the time claims (exp, nbf)
and returns the claims table — or nil and an error table such as
{ code = "expired" }. A forged, tampered or expired token is normal
input for this route, not an exception, so nothing needs pcall.
Protecting routes
Section titled “Protecting routes”Attach the middleware to a route, or to a whole group:
app:get("/profile", auth, function(req, res) res:json({ user_id = req.user.sub })end)
app:group("/api", { auth }, function(api) api:get("/orders", list_orders) -- auth runs first api:post("/orders", create_order)end)curl http://localhost:3000/profile# {"error":"Unauthorized"}
curl http://localhost:3000/profile \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."# {"user_id":"42"}Authorization: checking roles
Section titled “Authorization: checking roles”Authentication says who is calling; authorization says what they
may do. Once req.user exists, a role check is a middleware factory
stacked after auth:
local function role(name) return function(req, res, next) local roles = req.user.roles or {} for _, r in ipairs(roles) do if r == name then return next() end end res:status(403):json({ error = "Forbidden" }) endend
app:delete("/posts/:id", { auth, role("admin") }, function(req, res) -- only admins reach this handlerend)For the roles to be in req.user, put them in the token when signing:
jwt.sign({ sub = ..., roles = user.roles, exp = ... }, SECRET).
