Goravel auth middleware and login rate limiting
Part 10 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-10. In Register & Login with JWT, the to
Part 10 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-10.
In Register & Login with JWT, the token routes learned who is calling, but the task and user routes still answer anyone. Me and Logout also start with the same lines to read the token. In this part we move that check into a middleware that guards every route that needs a login, hand the signed-in user to the handlers, and limit how fast anyone can guess a password.
Start from the Register & Login with JWT code:
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-09
cp .env.example .env
go run . artisan key:generate
go run . artisan jwt:secret
docker compose up -d
go run . artisan migrate:fresh --seed
go install github.com/air-verse/air@latest
air
The auth middleware
A middleware is code that runs before the handler. It either passes the request on or answers it, so one login check can guard every route that needs it.
$ go run . artisan make:middleware Auth
SUCCESS Middleware created successfully
It writes app/http/middleware/auth.go:
app/http/middleware/auth.go
package middleware
import (
"github.com/goravel/framework/contracts/http"
)
type Auth struct{}
func (m *Auth) Signature() string {
return "Auth"
}
func (m *Auth) Handle(ctx http.Context) {
ctx.Request().Next()
}
-
Signaturenames the middleware. -
Handleruns for each request.ctx.Request().Next()passes the request on to the next middleware, or to the handler.
Fill in Handle with the Parse check from Me:
app/http/middleware/auth.go
package middleware
import (
+ "taskflow/app/facades"
+
"github.com/goravel/framework/contracts/http"
)
// ... 6 lines not shown
func (m *Auth) Handle(ctx http.Context) {
+ if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil {
+ ctx.Response().Json(401, map[string]any{
+ "error": err.Error(),
+ }).Abort()
+ return
+ }
+
ctx.Request().Next()
}
- If the token is missing, changed, expired or logged out, the middleware answers
401with the reason. -
Abortstops the request there, so the handler never runs.
Protecting routes
Register and log in stay open, since nobody has a token yet. Refresh stays open too, because it must accept an expired token, which the middleware refuses. Every other API route goes into a group behind the middleware:
routes/api.go
package routes
import (
"taskflow/app/facades"
"taskflow/app/http/controllers"
+ "taskflow/app/http/middleware"
"github.com/goravel/framework/contracts/route"
)
// ... 6 lines not shown
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
router.Post("register", authController.Register)
router.Post("login", authController.Login)
- router.Get("me", authController.Me)
router.Post("refresh", authController.Refresh)
- router.Post("logout", authController.Logout)
- router.Post("tasks", taskController.Store).Name("tasks.create")
- router.Get("tasks/{id}", taskController.Show)
- router.Get("tasks", taskController.Index)
+ router.Middleware(&middleware.Auth{}).Group(func(router route.Router) {
+ router.Get("me", authController.Me)
+ router.Post("logout", authController.Logout)
- router.Get("users", userController.Index)
- router.Get("users/{id}", userController.Show)
+ router.Post("tasks", taskController.Store).Name("tasks.create")
+ router.Get("tasks/{id}", taskController.Show)
+ router.Get("tasks", taskController.Index)
+
+ router.Get("users", userController.Index)
+ router.Get("users/{id}", userController.Show)
+ })
})
}
router.Middleware(&middleware.Auth{}) returns a router whose routes all run Auth first.
Without a token, the task list is refused now:
$ curl -i http://localhost:3000/api/v1/tasks
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
Content-Length: 43
{"error":"authentication token is invalid"}
Log in as Ana from the seeders, whose password is password, and keep the token in TOKEN as in Register & Login with JWT:
$ curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"password"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
$ TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
With the token, the list answers:
$ curl http://localhost:3000/api/v1/tasks -H "Authorization: Bearer $TOKEN"
{"filters":{},"page":1,"status":""}
In requests.http, the task and user requests need the header too:
requests.http
# ... 39 lines not shown
### Create task
POST http://localhost:3000/api/v1/tasks
+Authorization: Bearer {{token}}
Content-Type: application/json
# ... 5 lines not shown
### Create task with bad data
POST http://localhost:3000/api/v1/tasks
+Authorization: Bearer {{token}}
Content-Type: application/json
# ... 6 lines not shown
### Show task
GET http://localhost:3000/api/v1/tasks/1
+Authorization: Bearer {{token}}
### List tasks
GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
+Authorization: Bearer {{token}}
### List users
GET http://localhost:3000/api/v1/users
+Authorization: Bearer {{token}}
### Show user
GET http://localhost:3000/api/v1/users/2
+Authorization: Bearer {{token}}
### Hello
GET http://localhost:3000/hello
Middleware on every route:
router.Middleware puts a middleware on some routes. A middleware for every route, like one that logs each request, goes in bootstrap/app.go with WithMiddleware. See global middleware.
Laravel also has a guest middleware, which keeps logged-in users away from the login page. An API with tokens has no such pages, so TaskFlow doesn't need one.
One place to check the token
The middleware has parsed the token by the time Me and Logout run, so they can drop their own Parse lines. Refresh keeps its own:
app/http/controllers/auth_controller.go
// ... 115 lines not shown
func (r *AuthController) Me(ctx http.Context) http.Response {
- if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil {
- return ctx.Response().Json(401, map[string]any{
- "error": err.Error(),
- })
- }
-
// ... 41 lines not shown
func (r *AuthController) Logout(ctx http.Context) http.Response {
- if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil {
- return ctx.Response().Json(401, map[string]any{
- "error": err.Error(),
- })
- }
-
if err := facades.Auth(ctx).Logout(); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log out.",
})
}
return ctx.Response().NoContent()
}
They still work, because Parse leaves the token's claims on the request for User and Logout to read:
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
{"created_at":"2026-10-24 18:48:20","updated_at":"2026-10-24 18:48:20","id":1,"name":"Ana","email":"[email protected]","role":"manager","created_tasks":null,"assigned_tasks":null}
$ curl -i -X POST http://localhost:3000/api/v1/logout -H "Authorization: Bearer $TOKEN"
HTTP/1.1 204 No Content
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
{"error":"authentication token has been disabled"}
Gotcha
Jsononly builds the answer..Abort()stops the request and sends it. LeaveAbortout and the401is never sent, and the handler runs as if the middleware weren't there. With no token, the task list answers and/mefails with a500:$ curl -i http://localhost:3000/api/v1/tasks HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 Content-Length: 35 {"filters":{},"page":1,"status":""} $ curl -i http://localhost:3000/api/v1/me HTTP/1.1 500 Internal Server Error Content-Type: application/json; charset=utf-8 Content-Length: 36 {"error":"Could not load the user."}Nothing warns you, so every answer in a middleware needs
Abort.
A token for a deleted user
Parse checks the token's signature, its expiry and the logged-out list in Redis. It never asks Postgres, so a token keeps working after its user is gone. Register Dan, log him in, and delete him while his token is still valid:
$ curl -X POST http://localhost:3000/api/v1/register \
-H "Content-Type: application/json" \
-d '{"name":"Dan","email":"[email protected]","password":"secret123"}'
{"created_at":"2026-10-24 18:48:29","updated_at":"2026-10-24 18:48:29","id":5,"name":"Dan","email":"[email protected]","role":"member","created_tasks":null,"assigned_tasks":null}
$ curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"secret123"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
$ TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
$ docker compose exec postgres psql -U goravel -d taskflow -c "DELETE FROM users WHERE email = '[email protected]'"
DELETE 1
The task list still answers him, and only /me notices, because it is the one handler that loads the user:
$ curl http://localhost:3000/api/v1/tasks -H "Authorization: Bearer $TOKEN"
{"filters":{},"page":1,"status":""}
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
{"error":"The user no longer exists."}
Goravel's usual way is to call facades.Auth(ctx).User(&user) in the handlers that need the user. That only checks the routes whose handlers remember to call it, and each call runs its own query. Loading the user in the middleware checks every protected route, and the handlers get the user without asking again.
Passing the user to handlers
ctx.WithValue attaches a value to the request, and ctx.Value reads it back by its key. A small package that owns the key means the user can only be set and read through its two functions:
app/http/authuser/authuser.go
package authuser
import (
"context"
"taskflow/app/models"
"github.com/goravel/framework/contracts/http"
)
type key struct{}
func Set(ctx http.Context, user *models.User) {
ctx.WithValue(key{}, user)
}
func From(ctx context.Context) *models.User {
user, _ := ctx.Value(key{}).(*models.User)
return user
}
- The key is
key{}, a type only this package can make. A string like"user"could clash with other values on the request, the framework's included. Go's context docs advise the same. -
Settakes Goravel'shttp.Context. ItsWithValuechanges the request in place. Go's owncontext.WithValuereturns a new context, which the handler would never see. -
Fromtakes anycontext.Context, so code that only has acontext.Context, such as a gate, can read the user too. -
Fromreturnsnilwhen no user was set. Only call it in a handler behindAuth.
In the middleware, load the user after Parse and store it:
app/http/middleware/auth.go
package middleware
import (
+ "errors"
+
"taskflow/app/facades"
+ "taskflow/app/http/authuser"
+ "taskflow/app/models"
"github.com/goravel/framework/contracts/http"
+ frameworkerrors "github.com/goravel/framework/errors"
)
// ... 6 lines not shown
func (m *Auth) Handle(ctx http.Context) {
if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil {
ctx.Response().Json(401, map[string]any{
"error": err.Error(),
}).Abort()
return
}
+ var user models.User
+ if err := facades.Auth(ctx).User(&user); err != nil {
+ if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
+ ctx.Response().Json(401, map[string]any{
+ "error": "The user no longer exists.",
+ }).Abort()
+ return
+ }
+
+ ctx.Response().Json(500, map[string]any{
+ "error": "Could not load the user.",
+ }).Abort()
+ return
+ }
+
+ authuser.Set(ctx, &user)
ctx.Request().Next()
}
The two error answers come from Me, which becomes one line that reads the stored user with From:
app/http/controllers/auth_controller.go
package controllers
import (
"errors"
"sync"
"taskflow/app/facades"
+ "taskflow/app/http/authuser"
"taskflow/app/http/requests"
"taskflow/app/models"
"github.com/goravel/framework/auth"
"github.com/goravel/framework/contracts/http"
- frameworkerrors "github.com/goravel/framework/errors"
)
// ... 100 lines not shown
func (r *AuthController) Me(ctx http.Context) http.Response {
- var user models.User
- if err := facades.Auth(ctx).User(&user); err != nil {
- if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
- return ctx.Response().Json(401, map[string]any{
- "error": "The user no longer exists.",
- })
- }
-
- return ctx.Response().Json(500, map[string]any{
- "error": "Could not load the user.",
- })
- }
-
- return ctx.Response().Json(200, user)
+ return ctx.Response().Json(200, authuser.From(ctx))
}
// ... 34 lines not shown
Log in as Ana again and ask for /me:
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
GET /api/v1/me
{
"created_at": "2026-10-24 18:48:20",
"updated_at": "2026-10-24 18:48:20",
"id": 1,
"name": "Ana",
"email": "[email protected]",
"role": "manager",
"created_tasks": null,
"assigned_tasks": null
}
The /me request adds one query to the query log, from the middleware:
[2026-10-24 18:48:39.64] local.info: [1.189ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = '1'
Register Dan again, log him in and delete him. His token is now refused on the task list too:
$ curl http://localhost:3000/api/v1/tasks -H "Authorization: Bearer $TOKEN"
{"error":"The user no longer exists."}
A query on every request:
A JWT can be checked without the database, and that is much of its appeal. TaskFlow already asks Redis on each request whether the token was logged out, and now it reads the user from Postgres too, even for /tasks, which doesn't use it. That is one lookup by primary key, and a deleted user is shut out at once.
Limiting login attempts
Nothing stops a script from trying passwords at login all day. A rate limiter gives a rule a name, and the built-in Throttle middleware applies it to a route. Register it in the WithCallback from Models & Migrations:
bootstrap/app.go
package bootstrap
import (
"github.com/goravel/framework/contracts/database/schema"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
+ "github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
+ "github.com/goravel/framework/http/limit"
// ... 17 lines not shown
WithCallback(func() {
facades.Schema().Extend(schema.Extension{
Models: []any{models.User{}, models.Task{}},
})
+
+ facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
+ email := ctx.Request().Input("email")
+
+ return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
+ ctx.Response().Json(429, map[string]any{
+ "error": "Too many login attempts. Try again in a minute.",
+ }).Abort()
+ })
+ })
}).
Create()
}
-
PerMinute(5)allows five tries a minute. -
Bysets what is counted, here the email. A script guessing one account gets five tries a minute. In return, anyone who knows an email can keep its owner locked out by sending five requests a minute. -
Responsesets the answer once the limit is hit. Without it, the client gets a429with an empty body.
Put Throttle("login") on the login route. Goravel's middleware package has the same name as TaskFlow's, so it is imported as httpmiddleware:
routes/api.go
package routes
import (
"taskflow/app/facades"
"taskflow/app/http/controllers"
"taskflow/app/http/middleware"
"github.com/goravel/framework/contracts/route"
+ httpmiddleware "github.com/goravel/framework/http/middleware"
)
// ... 6 lines not shown
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
router.Post("register", authController.Register)
- router.Post("login", authController.Login)
+ router.Middleware(httpmiddleware.Throttle("login")).Post("login", authController.Login)
router.Post("refresh", authController.Refresh)
// ... 10 lines not shown
router.Get("users/{id}", userController.Show)
})
})
}
Try a wrong password six times:
$ for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:3000/api/v1/login -H "Content-Type: application/json" -d '{"email":"[email protected]","password":"wrong"}'; done; echo
401 401 401 401 401 429
The sixth gets 429. Logins that work count too, because Throttle counts every request. Counting only failures would mean moving the limit into the Login handler.
The email as it was typed
The limiter counts the email exactly as it was sent, so check whether another spelling gets past it. Within the minute, log in as Ana with the right password, then with capitals, then with a space in front:
$ curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"password"}'
{"error":"Too many login attempts. Try again in a minute."}
$ curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"password"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
$ curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":" [email protected]","password":"password"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
Ana's email is blocked, but both other spellings work. The limiter reads the email before the form request trims and lowercases it, so each spelling gets a counter of its own. Redis shows three:
$ docker compose exec redis redis-cli --scan --pattern '*throttle*'
Goravel_cache:throttle:login:0:[email protected]
Goravel_cache:throttle:login:0:[email protected]
Goravel_cache:throttle:login:0: [email protected]
Before passing the email to By, trim it and lowercase it, as the form request's filters do:
bootstrap/app.go
package bootstrap
import (
+ "strings"
+
// ... 28 lines not shown
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
- email := ctx.Request().Input("email")
+ email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
}).
Create()
}
Wait a minute for the old counters to run out. Now five wrong tries in different spellings share one counter, and the right password is refused too until the minute is up:
$ for email in "[email protected]" "[email protected]" " [email protected]" "[email protected]" "[email protected] "; do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:3000/api/v1/login -H "Content-Type: application/json" -d "{\"email\":\"$email\",\"password\":\"wrong\"}"; done; echo
401 401 401 401 401
$ curl -i -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"password"}'
HTTP/1.1 429 Too Many Requests
Content-Type: application/json; charset=utf-8
Retry-After: 60
X-Ratelimit-Limit: 5
X-Ratelimit-Remaining: 0
X-Ratelimit-Reset: 1792867791
Content-Length: 59
{"error":"Too many login attempts. Try again in a minute."}
-
X-Ratelimit-LimitandX-Ratelimit-Remainingsay how many tries the minute allows and how many are left. -
Retry-Afteris the number of seconds until the next try. -
X-Ratelimit-Resetis when the counter starts again, in Unix time.
There is one key in Redis now:
$ docker compose exec redis redis-cli --scan --pattern '*throttle*'
Goravel_cache:throttle:login:0:[email protected]
Register Eve and log her in. Her email has its own counter, so she gets through, with four tries left:
$ curl -X POST http://localhost:3000/api/v1/register \
-H "Content-Type: application/json" \
-d '{"name":"Eve","email":"[email protected]","password":"secret123"}'
{"created_at":"2026-10-24 18:48:52","updated_at":"2026-10-24 18:48:52","id":7,"name":"Eve","email":"[email protected]","role":"member","created_tasks":null,"assigned_tasks":null}
$ curl -i -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"secret123"}'
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Ratelimit-Limit: 5
X-Ratelimit-Remaining: 4
Content-Length: 171
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
What the email limit doesn't stop:
It doesn't stop one client from trying a common password against many emails, and register still tells anyone whether an email has an account. Both need a limit per client, and Goravel counts a client by IP. Throttle does that when a limiter has no key, counting by ctx.Request().Ip() and the path.
Counting the email and the IP together, as Laravel's starter kits do, is weaker than the email alone. With the Gin driver, Ip() trusts the X-Forwarded-For header from any client, so a client can send a new IP on every request, and one account gets unlimited tries. Change the key to the email and the IP:
bootstrap/app.go
// ... 33 lines not shown
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
- return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
+ return limit.PerMinute(5).By(email + "|" + ctx.Request().Ip()).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
}).
Create()
}
Lock Ana's email, then send a new header on each try:
$ for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:3000/api/v1/login -H "Content-Type: application/json" -d '{"email":"[email protected]","password":"wrong"}'; done; echo
401 401 401 401 401 429
$ for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:3000/api/v1/login -H "Content-Type: application/json" -H "X-Forwarded-For: 10.0.0.$i" -d '{"email":"[email protected]","password":"wrong"}'; done; echo
401 401 401 401 401 401
Every try gets a counter of its own, so the email never locks. Change the key back to By(email). TaskFlow counts by email only. A per-IP limit falls to the same header. A limit per client needs an IP the client can't fake, which means telling the server which proxies to trust.
The task and user routes now turn away anyone without a valid token, handlers get the signed-in user from the middleware, and login allows five tries a minute per email. A member can still do anything a manager can.
Previous: 09. Register & Login with JWT
Next: 11. Task CRUD
Series: Goravel
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.