DevChalk

Goravel series

Auth Middleware

Krishan Kumar · · 8 min See the changes ↗

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.

CodeStart from the Register & Login with JWT code
Terminal window
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.

Terminal window
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()
}
  • Signature names the middleware.
  • Handle runs 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 collapsed lines
type Auth struct{}
func (m *Auth) Signature() string {
return "Auth"
}
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 401 with the reason.
  • Abort stops 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 collapsed lines
func Api() {
authController := controllers.NewAuthController()
taskController := controllers.NewTaskController()
userController := controllers.NewUserController()
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:

Terminal window
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:

Terminal window
curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"ana@example.com","password":"password"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…

With the token, the list answers:

Terminal window
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 collapsed lines
### Register
POST http://localhost:3000/api/v1/register
Content-Type: application/json
{
"name": "Ben",
"email": "ben@example.com",
"password": "secret123"
}
### Log in
POST http://localhost:3000/api/v1/login
Content-Type: application/json
{
"email": "ben@example.com",
"password": "secret123"
}
> {%
client.global.set("token", response.body.token);
%}
### Me
GET http://localhost:3000/api/v1/me
Authorization: Bearer {{token}}
### Refresh
POST http://localhost:3000/api/v1/refresh
Authorization: Bearer {{token}}
> {%
client.global.set("token", response.body.token);
%}
### Log out
POST http://localhost:3000/api/v1/logout
Authorization: Bearer {{token}}
### Create task
POST http://localhost:3000/api/v1/tasks
Authorization: Bearer {{token}}
Content-Type: application/json
5 collapsed lines
{
"title": "Ship v1",
"description": "The first release"
}
### Create task with bad data
POST http://localhost:3000/api/v1/tasks
Authorization: Bearer {{token}}
Content-Type: application/json
6 collapsed lines
{
"title": " ",
"assigned_to": 99,
"due_at": "2020-01-01"
}
### 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
MoreMiddleware 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 collapsed lines
package controllers
import (
"errors"
"sync"
"taskflow/app/facades"
"taskflow/app/http/requests"
"taskflow/app/models"
"github.com/goravel/framework/auth"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
)
// noUserHash is a bcrypt hash of a password nobody has, made with the same cost as the
// real ones. An unknown email is checked against it, so it takes as long to refuse as a
// wrong password.
var noUserHash = sync.OnceValue(func() string {
hash, err := facades.Hash().Make("no user has this password")
if err != nil {
panic(err)
}
return hash
})
type AuthController struct {
// Dependent services
}
func NewAuthController() *AuthController {
return &AuthController{
// Inject services
}
}
func (r *AuthController) Register(ctx http.Context) http.Response {
var request requests.RegisterRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
password, err := facades.Hash().Make(request.Password)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
user := models.User{
Name: request.Name,
Email: request.Email,
Password: password,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&user); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
return ctx.Response().Json(201, user)
}
func (r *AuthController) Login(ctx http.Context) http.Response {
var request requests.LoginRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
var user models.User
if err := facades.Orm().WithContext(ctx).Query().Where("email", request.Email).First(&user); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
hash := user.Password
if user.ID == 0 {
hash = noUserHash()
}
if !facades.Hash().Check(request.Password, hash) || user.ID == 0 {
return ctx.Response().Json(401, map[string]any{
"error": "The email or password is wrong.",
})
}
token, err := facades.Auth(ctx).Login(&user)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
return ctx.Response().Json(200, map[string]any{
"token": token,
})
}
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 collapsed lines
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)
}
func (r *AuthController) Refresh(ctx http.Context) http.Response {
_, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization"))
if err != nil && !errors.Is(err, auth.ErrorTokenExpired) {
return ctx.Response().Json(401, map[string]any{
"error": err.Error(),
})
}
token, err := facades.Auth(ctx).Refresh()
if errors.Is(err, auth.ErrorRefreshTimeExceeded) {
return ctx.Response().Json(401, map[string]any{
"error": err.Error(),
})
}
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not refresh the token.",
})
}
return ctx.Response().Json(200, map[string]any{
"token": token,
})
}
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:

Terminal window
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":"ana@example.com","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 Json only builds the answer. .Abort() stops the request and sends it. Leave Abort out and the 401 is never sent, and the handler runs as if the middleware weren’t there. With no token, the task list answers and /me fails with a 500:

Terminal window
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:

Terminal window
curl -X POST http://localhost:3000/api/v1/register \
-H "Content-Type: application/json" \
-d '{"name":"Dan","email":"dan@example.com","password":"secret123"}'
{"created_at":"2026-10-24 18:48:29","updated_at":"2026-10-24 18:48:29","id":5,"name":"Dan","email":"dan@example.com","role":"member","created_tasks":null,"assigned_tasks":null}
curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"dan@example.com","password":"secret123"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
docker compose exec postgres psql -U goravel -d taskflow -c "DELETE FROM users WHERE email = 'dan@example.com'"
DELETE 1

The task list still answers him, and only /me notices, because it is the one handler that loads the user:

Terminal window
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.
  • Set takes Goravel’s http.Context. Its WithValue changes the request in place. Go’s own context.WithValue returns a new context, which the handler would never see.
  • From takes any context.Context, so code that only has a context.Context, such as a gate, can read the user too.
  • From returns nil when no user was set. Only call it in a handler behind Auth.

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 collapsed lines
type Auth struct{}
func (m *Auth) Signature() string {
return "Auth"
}
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 collapsed lines
// noUserHash is a bcrypt hash of a password nobody has, made with the same cost as the
// real ones. An unknown email is checked against it, so it takes as long to refuse as a
// wrong password.
var noUserHash = sync.OnceValue(func() string {
hash, err := facades.Hash().Make("no user has this password")
if err != nil {
panic(err)
}
return hash
})
type AuthController struct {
// Dependent services
}
func NewAuthController() *AuthController {
return &AuthController{
// Inject services
}
}
func (r *AuthController) Register(ctx http.Context) http.Response {
var request requests.RegisterRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
password, err := facades.Hash().Make(request.Password)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
user := models.User{
Name: request.Name,
Email: request.Email,
Password: password,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&user); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not create the account.",
})
}
return ctx.Response().Json(201, user)
}
func (r *AuthController) Login(ctx http.Context) http.Response {
var request requests.LoginRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
var user models.User
if err := facades.Orm().WithContext(ctx).Query().Where("email", request.Email).First(&user); err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
hash := user.Password
if user.ID == 0 {
hash = noUserHash()
}
if !facades.Hash().Check(request.Password, hash) || user.ID == 0 {
return ctx.Response().Json(401, map[string]any{
"error": "The email or password is wrong.",
})
}
token, err := facades.Auth(ctx).Login(&user)
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not log in.",
})
}
return ctx.Response().Json(200, map[string]any{
"token": token,
})
}
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 collapsed lines
func (r *AuthController) Refresh(ctx http.Context) http.Response {
_, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization"))
if err != nil && !errors.Is(err, auth.ErrorTokenExpired) {
return ctx.Response().Json(401, map[string]any{
"error": err.Error(),
})
}
token, err := facades.Auth(ctx).Refresh()
if errors.Is(err, auth.ErrorRefreshTimeExceeded) {
return ctx.Response().Json(401, map[string]any{
"error": err.Error(),
})
}
if err != nil {
return ctx.Response().Json(500, map[string]any{
"error": "Could not refresh the token.",
})
}
return ctx.Response().Json(200, map[string]any{
"token": token,
})
}
func (r *AuthController) Logout(ctx http.Context) http.Response {
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()
}

Log in as Ana again and ask for /me:

Terminal window
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": "ana@example.com",
"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:

Terminal window
curl http://localhost:3000/api/v1/tasks -H "Authorization: Bearer $TOKEN"
{"error":"The user no longer exists."}
MoreA 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 collapsed lines
"taskflow/app/facades"
"taskflow/app/models"
"taskflow/config"
"taskflow/routes"
)
func Boot() contractsfoundation.Application {
return foundation.Setup().
WithRules(Rules).
WithSeeders(Seeders).
WithMigrations(Migrations).
WithRouting(func() {
routes.Api()
routes.Web()
}).
WithProviders(Providers).
WithConfig(config.Boot).
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.
  • By sets 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.
  • Response sets the answer once the limit is hit. Without it, the client gets a 429 with 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 collapsed lines
func Api() {
authController := controllers.NewAuthController()
taskController := controllers.NewTaskController()
userController := controllers.NewUserController()
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 collapsed lines
router.Middleware(&middleware.Auth{}).Group(func(router route.Router) {
router.Get("me", authController.Me)
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.Get("users", userController.Index)
router.Get("users/{id}", userController.Show)
})
})
}

Try a wrong password six times:

Terminal window
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":"ana@example.com","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:

Terminal window
curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"ana@example.com","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":"ANA@EXAMPLE.COM","password":"password"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":" ana@example.com","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:

Terminal window
docker compose exec redis redis-cli --scan --pattern '*throttle*'
Goravel_cache:throttle:login:0:ANA@EXAMPLE.COM
Goravel_cache:throttle:login:0:ana@example.com
Goravel_cache:throttle:login:0: ana@example.com

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 collapsed lines
"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"
"taskflow/app/facades"
"taskflow/app/models"
"taskflow/config"
"taskflow/routes"
)
func Boot() contractsfoundation.Application {
return foundation.Setup().
WithRules(Rules).
WithSeeders(Seeders).
WithMigrations(Migrations).
WithRouting(func() {
routes.Api()
routes.Web()
}).
WithProviders(Providers).
WithConfig(config.Boot).
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")
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:

Terminal window
for email in "ana@example.com" "Ana@Example.com" " ana@example.com" "ANA@EXAMPLE.COM" "ana@example.com "; 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":"ana@example.com","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-Limit and X-Ratelimit-Remaining say how many tries the minute allows and how many are left.
  • Retry-After is the number of seconds until the next try.
  • X-Ratelimit-Reset is when the counter starts again, in Unix time.

There is one key in Redis now:

Terminal window
docker compose exec redis redis-cli --scan --pattern '*throttle*'
Goravel_cache:throttle:login:0:ana@example.com

Register Eve and log her in. Her email has its own counter, so she gets through, with four tries left:

Terminal window
curl -X POST http://localhost:3000/api/v1/register \
-H "Content-Type: application/json" \
-d '{"name":"Eve","email":"eve@example.com","password":"secret123"}'
{"created_at":"2026-10-24 18:48:52","updated_at":"2026-10-24 18:48:52","id":7,"name":"Eve","email":"eve@example.com","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":"eve@example.com","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…"}
MoreWhat 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 collapsed lines
package bootstrap
import (
"strings"
"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"
"taskflow/app/facades"
"taskflow/app/models"
"taskflow/config"
"taskflow/routes"
)
func Boot() contractsfoundation.Application {
return foundation.Setup().
WithRules(Rules).
WithSeeders(Seeders).
WithMigrations(Migrations).
WithRouting(func() {
routes.Api()
routes.Web()
}).
WithProviders(Providers).
WithConfig(config.Boot).
WithCallback(func() {
facades.Schema().Extend(schema.Extension{
Models: []any{models.User{}, models.Task{}},
})
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:

Terminal window
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":"ana@example.com","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":"ana@example.com","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.