Skip to content

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:

Terminal window
luarocks install bcryptlua # password hashing
luarocks install jsonwebtoken # signing and verifying JWTs

bcryptlua is covered in the hashing guide; jsonwebtoken signs, verifies and decodes tokens in pure Lua — no OpenSSL, nothing to compile.

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.

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:

Terminal window
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "maria@example.com", "password": "guess"}'
# {"access_token":"eyJhbGciOiJIUzI1NiIs..."}

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()
end

jwt.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.

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)
Terminal window
curl http://localhost:3000/profile
# {"error":"Unauthorized"}
curl http://localhost:3000/profile \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
# {"user_id":"42"}

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" })
end
end
app:delete("/posts/:id", { auth, role("admin") }, function(req, res)
-- only admins reach this handler
end)

For the roles to be in req.user, put them in the token when signing: jwt.sign({ sub = ..., roles = user.roles, exp = ... }, SECRET).