# Auth Middleware & Rate Limits

> Routes only signed-in users can call.

Goravel series, part 10 of 20. By Krishan Kumar. Published 2026-10-25.
Source: https://devchalk.com/goravel/10-auth-middleware
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-10

In [Register & Login with JWT](https://devchalk.com/goravel/09-register-login-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:

```bash
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](https://www.goravel.dev/the-basics/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.

```bash
$ go run . artisan make:middleware Auth
 SUCCESS  Middleware created successfully
```

It writes `app/http/middleware/auth.go`:

```go title="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`:

```diff lang="go" title="app/http/middleware/auth.go" collapse={9-14} t2:parse=/Auth.ctx.[.](Parse)/ t1:abort=/[.](Abort)[(]/
 package middleware
 
 import (
+	"taskflow/app/facades"
+
 	"github.com/goravel/framework/contracts/http"
 )
 
 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:

```diff lang="go" title="routes/api.go" collapse={10-15}
 package routes
 
 import (
 	"taskflow/app/facades"
 	"taskflow/app/http/controllers"
+	"taskflow/app/http/middleware"
 
 	"github.com/goravel/framework/contracts/route"
 )
 
 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:

```bash
$ 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](https://devchalk.com/goravel/09-register-login-jwt#the-login-handler):

```bash
$ 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:

```bash
$ 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:

```diff lang="http" title="requests.http" collapse={1-39} collapse={45-49} collapse={55-60}
 ### 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
 
 {
   "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
 
 {
   "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
```

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](https://www.goravel.dev/the-basics/middleware#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:

```diff lang="go" title="app/http/controllers/auth_controller.go" collapse={1-115} collapse={123-163} t2:parse=/Auth.ctx.[.](Parse)/
 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(),
-		})
-	}
-
 	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:

```bash
$ 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`:
>
> ```bash t1:abort=/(200 OK)/
> $ 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:

```bash
$ 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:

```bash
$ 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:

```go title="app/http/authuser/authuser.go" t3:user=/func (Set|From)[(]/
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](https://pkg.go.dev/context#WithValue) 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:

```diff lang="go" title="app/http/middleware/auth.go" collapse={14-19} t3:user=/authuser[.](Set)[(]/
 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"
 )
 
 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`:

```diff lang="go" title="app/http/controllers/auth_controller.go" collapse={17-116} collapse={135-168} t3:user=/authuser[.](From)[(]/
 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"
 )
 
 // 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))
 }
 
 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`:

```bash
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
```

```json title="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](https://devchalk.com/goravel/05-relationships-belongs-to#the-query-log), from the middleware:

```text wrap
[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:

```bash
$ 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](https://www.goravel.dev/the-basics/routing#rate-limiting) gives a rule a name, and the built-in `Throttle` middleware applies it to a route. Register it in the `WithCallback` from [Models & Migrations](https://devchalk.com/goravel/04-models-migrations#the-migration):

```diff lang="go" title="bootstrap/app.go" collapse={10-26} t1:key=/By[(](email)[)]/
 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"
 
 	"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`:

```diff lang="go" title="routes/api.go" collapse={11-16} collapse={22-31}
 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"
 )
 
 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)
 
 		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:

```bash
$ 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:

```bash
$ 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:

```bash t1:key=/login:0:(.+)$/
$ 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](https://devchalk.com/goravel/09-register-login-jwt#registering) do:

```diff lang="go" title="bootstrap/app.go" collapse={6-33}
 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 := 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:

```bash
$ 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:

```bash t1:key=/login:0:(.+)$/
$ 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:

```bash
$ 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…"}
```

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:

```diff lang="go" title="bootstrap/app.go" collapse={1-33}
 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:

```bash
$ 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.

---

Previous: [09. Register & Login with JWT](https://devchalk.com/goravel/09-register-login-jwt.md)
Next: [11. Task CRUD](https://devchalk.com/goravel/11-task-crud.md)
Series: [Goravel](https://devchalk.com/goravel.md)
