# Register & Login with JWT

> Accounts, hashed passwords, signed tokens.

Goravel series, part 9 of 20. By Krishan Kumar. Published 2026-10-24, Updated 2026-10-25.
Source: https://devchalk.com/goravel/09-register-login-jwt
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-09

TaskFlow has users, but nobody can sign up or log in. Its clients may be mobile apps or scripts, not only browsers, so instead of a session cookie, login returns a token the client sends in a header. In this part we register users with a hashed password, log them in, and read the signed token on later requests. Then we log out and refresh tokens.

Start from the Validation with Form Requests code:

```bash
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-08
cp .env.example .env
go run . artisan key:generate
docker compose up -d
go run . artisan migrate:fresh --seed
go install github.com/air-verse/air@latest
air
```

## Registering

To sign up, a client sends a name, an email and a password. We check them before storing anything. The email must be valid and not taken yet, and the password long enough. A [form request](https://www.goravel.dev/the-basics/validation#form-request-validation) does that, as in [Validation with Form Requests](https://devchalk.com/goravel/08-validation-form-requests#form-requests):

```bash
$ go run . artisan make:request RegisterRequest
 SUCCESS  Request created successfully
```

Fill it in, and delete the methods it doesn't use:

```go title="app/http/requests/register_request.go" t1:unique=/(unique):users,email/ t2:lower=/"trim[|](lower)"/
package requests

import (
	"github.com/goravel/framework/contracts/http"
)

type RegisterRequest struct {
	Name     string `form:"name" json:"name"`
	Email    string `form:"email" json:"email"`
	Password string `form:"password" json:"password"`
}

func (r *RegisterRequest) Authorize(ctx http.Context) error {
	return nil
}

func (r *RegisterRequest) Rules(ctx http.Context) map[string]any {
	return map[string]any{
		"name":     "required|string|max:255",
		"email":    "bail|required|string|email|max:255|unique:users,email",
		"password": "bail|required|string|min:8|max_bytes:72",
	}
}

func (r *RegisterRequest) Filters(ctx http.Context) map[string]any {
	return map[string]any{
		"name":  "trim",
		"email": "trim|lower",
	}
}
```

- `lower` turns `Ben@Example.com` into `ben@example.com`. Filters run before rules, and Postgres compares text exactly, so without it the same person could sign up twice.
- `unique` looks the email up in `users`. `bail` stops at the first rule that fails, so a malformed email never reaches Postgres.
- Two sign-ups at the same moment can both pass `unique`. The unique index from [Models & Migrations](https://devchalk.com/goravel/04-models-migrations) then stops the second one.
- There is no `role` field, so a client can't make itself a manager.

We never store the password itself, only a [bcrypt](https://www.goravel.dev/security/hashing) hash of it. bcrypt reads at most 72 bytes of a password and refuses longer ones with an error, so the password needs a limit in bytes. Goravel has no such rule, so we make one:

Why not max:72:

`max:72` counts characters, not bytes. An emoji is 4 bytes, so a password of 20 emoji passes `max:72` and then fails inside `Hash().Make` with a `500`.

```bash
$ go run . artisan make:rule MaxBytes
 SUCCESS  Rule created successfully
 SUCCESS  Rule registered successfully
```

```diff lang="go" title="app/rules/max_bytes.go" collapse={10-18}
 package rules
 
 import (
 	"context"
+	"fmt"
+	"strconv"
 
 	"github.com/goravel/framework/contracts/validation"
 )
 
 type MaxBytes struct {
 }
 
 // Signature The name of the rule.
 func (receiver *MaxBytes) Signature() string {
 	return "max_bytes"
 }
 
 // Passes Determine if the validation rule passes.
 func (receiver *MaxBytes) Passes(ctx context.Context, data validation.Data, val any, options ...any) bool {
-	return true
+	value, ok := val.(string)
+	if !ok || len(options) == 0 {
+		return false
+	}
+
+	limit, err := strconv.Atoi(fmt.Sprint(options[0]))
+	if err != nil {
+		return false
+	}
+
+	return len(value) <= limit
 }
 
 // Message Get the validation error message.
 func (receiver *MaxBytes) Message(ctx context.Context) string {
-	return ""
+	return "The :attribute must not be longer than :option0 bytes."
 }
```

- The `72` in `max_bytes:72` arrives as `options[0]`, and the message shows it as `:option0`.
- The message doesn't use `:value`, which would put the password in the response.
- `make:rule` also added the rule to `bootstrap/rules.go`.

Now the handler. Registering, logging in and the rest belong together, so they get a controller of their own:

```bash
$ go run . artisan make:controller AuthController
 SUCCESS  Controller created successfully
```

Replace its `Index` with `Register`:

```diff lang="go" title="app/http/controllers/auth_controller.go" collapse={11-19} t3:hash=/Hash..[.](Make)/
 package controllers
 
 import (
+	"taskflow/app/facades"
+	"taskflow/app/http/requests"
+	"taskflow/app/models"
+
 	"github.com/goravel/framework/contracts/http"
 )
 
 type AuthController struct {
 	// Dependent services
 }
 
 func NewAuthController() *AuthController {
 	return &AuthController{
 		// Inject services
 	}
 }
 
-func (r *AuthController) Index(ctx http.Context) http.Response {
-	return nil
+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)
 }
```

- `Hash().Make` turns the password into a bcrypt hash, and only the hash is saved.
- `WithContext(ctx)` gives the query the request's context, so the query stops if the client goes away.
- When something fails on the server, the client gets a plain message, because `err.Error()` can show table names, column names or SQL.

The handlers from earlier parts have neither of the last two yet. They get both when we build the task endpoints.

```diff lang="go" title="routes/api.go" collapse={1-9}
 package routes
 
 import (
 	"taskflow/app/facades"
 	"taskflow/app/http/controllers"
 
 	"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("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)
 	})
 }
```

```diff lang="http" title="requests.http" collapse={15-43}
+### Register
+POST http://localhost:3000/api/v1/register
+Content-Type: application/json
+
+{
+  "name": "Ben",
+  "email": "ben@example.com",
+  "password": "secret123"
+}
+
 ### Create task
 POST http://localhost:3000/api/v1/tasks
 Content-Type: application/json
 
 {
   "title": "Ship v1",
   "description": "The first release"
 }
 
 ### Create task with bad data
 POST http://localhost:3000/api/v1/tasks
 Content-Type: application/json
 
 {
   "title": "   ",
   "assigned_to": 99,
   "due_at": "2020-01-01"
 }
 
 ### Show task
 GET http://localhost:3000/api/v1/tasks/1
 
 ### List tasks
 GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
 
 ### List users
 GET http://localhost:3000/api/v1/users
 
 ### Show user
 GET http://localhost:3000/api/v1/users/2
 
 ### Hello
 GET http://localhost:3000/hello
```

Sign Ben up:

```bash
$ curl -X POST http://localhost:3000/api/v1/register \
    -H "Content-Type: application/json" \
    -d '{"name":"Ben","email":"ben@example.com","password":"secret123"}'
```

```json title="POST /api/v1/register"
{
  "created_at": "2026-10-24 13:34:22",
  "updated_at": "2026-10-24 13:34:22",
  "id": 5,
  "name": "Ben",
  "email": "ben@example.com",
  "role": "member",
  "created_tasks": null,
  "assigned_tasks": null
}
```

The answer is `201 Created`, without the password, because of its `json:"-"` tag from [Relationships: Belongs To](https://devchalk.com/goravel/05-relationships-belongs-to#json-field-names). The [query log](https://devchalk.com/goravel/05-relationships-belongs-to#the-query-log) shows the email check, then the row with the hash from `Hash().Make`. GORM filled in `member` from the model's `default` tag:

```text wrap t3:hash=/'([$]2a[$]12[$][^']+)'/
[2026-10-24 13:34:21.965] local.info: [6.707ms] [rows:1] SELECT count(*) FROM "users" WHERE "email" = 'ben@example.com'
[2026-10-24 13:34:22.148] local.info: [2.475ms] [rows:1] INSERT INTO "users" ("created_at","updated_at","name","email","password","role") VALUES ('2026-10-24 13:34:22.145','2026-10-24 13:34:22.145','Ben','ben@example.com','$2a$12$Q.2kAvGjrc25EDfZAv6FEeGnD5EuyLn4BRjkRWcswCwvQgSy.eH7y','member') RETURNING "id"
```

The same email in capitals is turned away by `unique`:

```bash
$ curl -X POST http://localhost:3000/api/v1/register \
    -H "Content-Type: application/json" \
    -d '{"name":"Ben","email":"Ben@Example.com","password":"secret123"}'
```

```json title="POST /api/v1/register" t1:unique=/"(unique)":/
{
  "errors": {
    "email": {
      "unique": "The email has already been taken."
    }
  }
}
```

The query log shows why. `lower` made the email lowercase before `unique` looked:

```text wrap t2:lower=/'(ben@example.com)'/
[2026-10-24 13:34:22.162] local.info: [0.863ms] [rows:1] SELECT count(*) FROM "users" WHERE "email" = 'ben@example.com'
```

A `"role": "manager"` in the body is ignored, and Cara gets a member account:

```bash
$ curl -X POST http://localhost:3000/api/v1/register \
    -H "Content-Type: application/json" \
    -d '{"name":"Cara","email":"cara@example.com","password":"secret123","role":"manager"}'
```

```json title="POST /api/v1/register"
{
  "created_at": "2026-10-24 13:34:22",
  "updated_at": "2026-10-24 13:34:22",
  "id": 6,
  "name": "Cara",
  "email": "cara@example.com",
  "role": "member",
  "created_tasks": null,
  "assigned_tasks": null
}
```

## Logging in

Ben has an account. Now he needs a way to prove it's him on every request. When he logs in, the API gives him a [JWT](https://www.goravel.dev/security/authentication#generate-jwt-token), a token that says which user he is. It is signed, so any change to it shows. The signing needs a secret, so we set that up first.

### The JWT secret

The server signs tokens with `JWT_SECRET` from `.env`, which is empty in a fresh clone. Generate one:

```bash
$ go run . artisan jwt:secret
 SUCCESS  Jwt Secret set successfully
```

Anyone with the secret can sign a token for any user, so it stays in `.env`, which git ignores. Running it again prints `exist jwt secret` and leaves the secret alone. It only fills in an existing `JWT_SECRET=` line. If the line is missing, it still prints `SUCCESS` but writes nothing.

The signing is done by a guard. A guard decides how a request proves who it is. Ours, `user`, came with the project. It uses JWT, and its `orm` provider loads users from the database:

```go title="config/auth.go" collapse={1-17} collapse={19-29} t2:guard=/"(user)": map/
package config

import (
	"taskflow/app/facades"
)

func init() {
	config := facades.Config()
	config.Add("auth", map[string]any{
		// Authentication Defaults
		//
		// This option controls the default authentication "guard"
		// reset options for your application. You may change these defaults
		// as required, but they're a perfect start for most applications.
		"defaults": map[string]any{
			"guard": "user",
		},

		// Authentication Guards
		//
		// Next, you may define every authentication guard for your application.
		// Of course, a great default configuration has been defined for you
		// here which uses session storage and the Eloquent user provider.
		//
		// All authentication drivers have a user provider. This defines how the
		// users are actually retrieved out of your database or other storage
		// mechanisms used by this application to persist your user's data.
		//
		// Supported drivers: "jwt", "session"
		"guards": map[string]any{
			"user": map[string]any{
				"driver":   "jwt",
				"provider": "user",
			},
		},

		// Supported: "orm"
		"providers": map[string]any{
			"user": map[string]any{
				"driver": "orm",
			},
		},
	})
}
```

Two settings in `config/jwt.go` set how long tokens last. `JWT_TTL` is 60 minutes by default. `JWT_REFRESH_TTL` is two weeks, and [Refreshing a token](#refreshing-a-token) covers it.

> **Gotcha** Without `JWT_SECRET`, a correct login and every route that reads a token answer `500 Internal Server Error` with an empty body. The reason, `authentication secret is missing or required`, is only in `storage/logs`.

### The login handler

Logging in means finding the user by email, checking the password, and handing back a token. Like registering, it starts with a form request:

```bash
$ go run . artisan make:request LoginRequest
 SUCCESS  Request created successfully
```

```go title="app/http/requests/login_request.go"
package requests

import (
	"github.com/goravel/framework/contracts/http"
)

type LoginRequest struct {
	Email    string `form:"email" json:"email"`
	Password string `form:"password" json:"password"`
}

func (r *LoginRequest) Authorize(ctx http.Context) error {
	return nil
}

func (r *LoginRequest) Rules(ctx http.Context) map[string]any {
	return map[string]any{
		"email":    "required|string|email",
		"password": "bail|required|string|max_bytes:72",
	}
}

func (r *LoginRequest) Filters(ctx http.Context) map[string]any {
	return map[string]any{
		"email": "trim|lower",
	}
}
```

```diff lang="go" title="app/http/controllers/auth_controller.go" collapse={25-68} t3:nouser=/(noUserHash)/
 package controllers
 
 import (
+	"sync"
+
 	"taskflow/app/facades"
 	"taskflow/app/http/requests"
 	"taskflow/app/models"
 
 	"github.com/goravel/framework/contracts/http"
 )
+
+// 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,
+	})
+}
```

`Login` does four things, in this order:

1. The query finds the user by email. When nobody has it, `First` returns no error and `user.ID` stays `0`.
2. `Hash().Check` compares the password with the stored hash. It runs before the `user.ID` test, so bcrypt runs either way.
3. `user.ID == 0` then refuses an unknown email, even if someone sent the right password for `noUserHash`.
4. [`Auth().Login`](https://www.goravel.dev/security/authentication#generate-token-using-user) signs a token for the user.

A wrong password and an unknown email get the same `401`, so the login route doesn't tell anyone which emails have accounts. Register still does, with "already been taken", which is the usual trade-off for a sign-up form. The response time could still give it away. bcrypt is slow on purpose, so an unknown email that skipped it would answer noticeably faster. `noUserHash` makes both cases run bcrypt. It is made on first use, with the same cost as `rounds` in `config/hashing.go`.

`LoginRequest` has `max_bytes:72` too. bcrypt reads only the first 72 bytes when it compares, so without the rule, a 72-byte password with anything added would still log in.

bcrypt also slows down each guess, but nothing yet limits how many a script can send. A limit on the login route comes with Auth Middleware & Rate Limits.

```diff lang="go" title="routes/api.go" collapse={1-9}
 package routes
 
 import (
 	"taskflow/app/facades"
 	"taskflow/app/http/controllers"
 
 	"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.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)
 	})
 }
```

The login request saves the `token` it gets back, so the requests below it can send it. The script after `>` runs when the response arrives. That syntax is GoLand's [response handler](https://www.jetbrains.com/help/go/http-response-handling-api-reference.html).

```diff lang="http" title="requests.http" collapse={1-4} collapse={28-56} t1:token=/set."(token)"/
 ### 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);
+%}
 
 ### Create task
 POST http://localhost:3000/api/v1/tasks
 Content-Type: application/json
 
 {
   "title": "Ship v1",
   "description": "The first release"
 }
 
 ### Create task with bad data
 POST http://localhost:3000/api/v1/tasks
 Content-Type: application/json
 
 {
   "title": "   ",
   "assigned_to": 99,
   "due_at": "2020-01-01"
 }
 
 ### Show task
 GET http://localhost:3000/api/v1/tasks/1
 
 ### List tasks
 GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
 
 ### List users
 GET http://localhost:3000/api/v1/users
 
 ### Show user
 GET http://localhost:3000/api/v1/users/2
 
 ### Hello
 GET http://localhost:3000/hello
```

In VS Code:

REST Client runs the same file. Put `# @name login` above the login request, and use `{{login.response.body.$.token}}` where the requests below it use `{{token}}`.

Log Ben in:

```bash
$ curl -X POST http://localhost:3000/api/v1/login \
    -H "Content-Type: application/json" \
    -d '{"email":"ben@example.com","password":"secret123"}'
```

```json title="POST /api/v1/login" wrap t1:token=/"(token)":/
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiI1Iiwic3ViIjoidXNlciIsImV4cCI6MTc5Mjg1MjQ2NSwiaWF0IjoxNzkyODQ4ODY1fQ.A55FXego6oS5Usv9ae95rIN2J4vFqMR5W3oFz6AurEw"
}
```

With curl, paste your whole token into a variable for the next requests:

```bash
$ TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
```

The server keeps no record of the login. It reads one row and writes nothing:

```text wrap
[2026-10-24 13:34:25.59] local.info: [8.000ms] [rows:1] SELECT * FROM "users" WHERE "email" = 'ben@example.com' ORDER BY "users"."id" LIMIT 1
```

A wrong password and an unknown email both get:

```json title="POST /api/v1/login"
{
  "error": "The email or password is wrong."
}
```

Timing the two failures:

Both take as long, because both run bcrypt. These timings are from my machine. Yours will differ, but the two should match. The very first unknown email after a restart is slower once, while `noUserHash` is made:

```bash
$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
  -X POST http://localhost:3000/api/v1/login \
    -H "Content-Type: application/json" -d '{"email":"ben@example.com","password":"wrong-one"}'
401 0.166280s

$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
  -X POST http://localhost:3000/api/v1/login \
    -H "Content-Type: application/json" -d '{"email":"nobody@example.com","password":"wrong-one"}'
401 0.166348s
```

## What a token holds

Ben now has a token, so let's look inside it. A token is three parts joined by dots: a header, a payload and a signature. The first two are JSON in base64url, so anyone can read them. Paste your token below, and add your `JWT_SECRET` to check the signature. Use it only with a local secret, never a production one.

With JavaScript on, this box decodes a token in your browser and checks its signature with your JWT_SECRET. A token is three base64url parts joined by dots: a header, a payload anyone can read, and a signature made with the secret.

The example token is Ben's, signed with the secret `taskflow-demo-secret`. It has expired. Yours is valid for an hour after you log in. Its payload holds these claims:

- `key` is the user's id.
- `sub` is the guard, `user`. Goravel puts the guard name there, where most systems put the user id.
- `iat` and `exp` are when the token was issued and when it expires, in Unix seconds. A client can read `exp` to know when to refresh, without asking the server.

Nothing in the payload is secret. What protects it is the signature, made from the header, the payload and `JWT_SECRET`. Turn on "Change to 1" and the token claims to be user 1, Ana, the manager. The signature no longer matches, so the API refuses it.

## Reading the token

A token is only useful if the API reads it back. The simplest route that needs it answers "who am I?":

```diff lang="go" title="app/http/controllers/auth_controller.go" collapse={15-113} t1:header=/Header."(Authorization)"/
 package controllers
 
 import (
+	"errors"
 	"sync"
 
 	"taskflow/app/facades"
 	"taskflow/app/http/requests"
 	"taskflow/app/models"
 
 	"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)
+}
```

- [`Parse`](https://www.goravel.dev/security/authentication#parse-token) takes the `Authorization` header and removes `Bearer ` itself. Its errors are fixed messages like `authentication token has expired`, so they are safe to send.
- `User` loads the user whose id is in `key`, with the request's context.
- If that user has been deleted, `Me` says so, and any other error stays on the server.

Every route that needs a logged-in user repeats these lines, until Auth Middleware & Rate Limits moves them in front of the routes.

```diff lang="go" title="routes/api.go" collapse={1-9}
 package routes
 
 import (
 	"taskflow/app/facades"
 	"taskflow/app/http/controllers"
 
 	"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("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)
 	})
 }
```

`{{token}}` is the token the login request saved:

```diff lang="http" title="requests.http" collapse={1-19} collapse={32-60} t1:header=/(Authorization): Bearer/
 ### 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}}
 
 ### Create task
 POST http://localhost:3000/api/v1/tasks
 Content-Type: application/json
 
 {
   "title": "Ship v1",
   "description": "The first release"
 }
 
 ### Create task with bad data
 POST http://localhost:3000/api/v1/tasks
 Content-Type: application/json
 
 {
   "title": "   ",
   "assigned_to": 99,
   "due_at": "2020-01-01"
 }
 
 ### Show task
 GET http://localhost:3000/api/v1/tasks/1
 
 ### List tasks
 GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
 
 ### List users
 GET http://localhost:3000/api/v1/users
 
 ### Show user
 GET http://localhost:3000/api/v1/users/2
 
 ### Hello
 GET http://localhost:3000/hello
```

```bash t1:header=/"(Authorization): Bearer/
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
```

```json title="GET /api/v1/me"
{
  "created_at": "2026-10-24 13:34:22",
  "updated_at": "2026-10-24 13:34:22",
  "id": 5,
  "name": "Ben",
  "email": "ben@example.com",
  "role": "member",
  "created_tasks": null,
  "assigned_tasks": null
}
```

The API trusts the signature and reads the user by the id in the token:

```text wrap t3:key=/= '(5)'/
[2026-10-24 13:35:11.682] local.info: [2.067ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = '5'
```

## Bad and expired tokens

No header and a changed token get the same `401`:

```bash
$ curl http://localhost:3000/api/v1/me
{"error":"authentication token is invalid"}

$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer ${TOKEN}x"
{"error":"authentication token is invalid"}
```

To see an expired token, give tokens a one-minute life. A variable set on the command line wins over `.env`. Stop `air`, start it again with `JWT_TTL=1`, log in, set `TOKEN`, and wait a minute:

```bash
$ JWT_TTL=1 air
```

```bash
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
{"error":"authentication token has expired"}
```

Then stop it, start plain `air` again, log in, and set `TOKEN` to the new token.

## Logging out

Logging out sounds simple, but the server can't take back a token it has signed, and it stays valid until it expires. Logout is the one place the server has to remember something about a token. It [marks the token as disabled](https://www.goravel.dev/security/authentication#logout) in the cache, Redis here, and `Parse` checks that mark before anything else. Step through a whole session to see where each request goes:

*One session against TaskFlow, one message at a time.*

Register: the unique rule counts users with the email, the API hashes the password with bcrypt and inserts one row. Log in: the API selects the user by email, checks the password against the hash, and signs a token with JWT_SECRET; nothing is written to Postgres or Redis. /me: the API asks Redis whether the token was logged out, checks the signature, and loads the user by the id in the token. Log out: the API writes the token into Redis as disabled for 60 minutes. /me again: Redis has the token, so the API answers 401 before checking the signature.

`Logout` needs to know which token to disable, so it parses the header first:

```diff lang="go" title="app/http/controllers/auth_controller.go" collapse={1-136} t1:logout=/Auth.ctx.[.](Logout)[(]/
 package controllers
 
 import (
 	"errors"
 	"sync"
 
 	"taskflow/app/facades"
 	"taskflow/app/http/requests"
 	"taskflow/app/models"
 
 	"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) 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()
+}
```

```diff lang="go" title="routes/api.go" collapse={1-9}
 package routes
 
 import (
 	"taskflow/app/facades"
 	"taskflow/app/http/controllers"
 
 	"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("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)
 	})
 }
```

```diff lang="http" title="requests.http" collapse={1-23} collapse={36-64}
 ### 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}}
 
+### Log out
+POST http://localhost:3000/api/v1/logout
+Authorization: Bearer {{token}}
+
 ### Create task
 POST http://localhost:3000/api/v1/tasks
 Content-Type: application/json
 
 {
   "title": "Ship v1",
   "description": "The first release"
 }
 
 ### Create task with bad data
 POST http://localhost:3000/api/v1/tasks
 Content-Type: application/json
 
 {
   "title": "   ",
   "assigned_to": 99,
   "due_at": "2020-01-01"
 }
 
 ### Show task
 GET http://localhost:3000/api/v1/tasks/1
 
 ### List tasks
 GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
 
 ### List users
 GET http://localhost:3000/api/v1/users
 
 ### Show user
 GET http://localhost:3000/api/v1/users/2
 
 ### Hello
 GET http://localhost:3000/hello
```

Start `docker compose exec redis redis-cli MONITOR | grep jwt` in a second terminal, then log out:

```bash
$ curl -i -X POST http://localhost:3000/api/v1/logout -H "Authorization: Bearer $TOKEN"
HTTP/1.1 204 No Content
```

`MONITOR` shows the check, then the mark that `Logout` writes. The mark lasts `JWT_TTL`, 3600 seconds:

```text wrap t1:logout=/"(set)"/
1792848916.663187 [0 172.18.0.1:59692] "get" "Goravel_cache:jwt:disabled:<token>"
1792848916.664079 [0 172.18.0.1:59692] "set" "Goravel_cache:jwt:disabled:<token>" "1" "ex" "3600"
```

The same token is refused now:

```bash
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
{"error":"authentication token has been disabled"}
```

## Refreshing a token

[`Refresh`](https://www.goravel.dev/security/authentication#refresh-token) swaps a token for a new one. Ben's token expires after an hour, and logging in again every hour would be a chore, so the client trades it in instead. It works on an expired token too, for up to `JWT_REFRESH_TTL` after it expires:

```diff lang="go" title="app/http/controllers/auth_controller.go" collapse={16-138} collapse={164-178} t1:expired=/auth[.](ErrorTokenExpired)/
 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()
 }
```

`Parse` returns `auth.ErrorTokenExpired` for an expired token, but still reads it, so `Refresh` knows whose token it is. `errors.Is` checks for that error, and any other error still stops the request. `Refresh` answers `401` when the refresh window has passed, and a plain `500` for anything else.

```diff lang="go" title="routes/api.go" collapse={1-9}
 package routes
 
 import (
 	"taskflow/app/facades"
 	"taskflow/app/http/controllers"
 
 	"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.Get("users", userController.Index)
 		router.Get("users/{id}", userController.Show)
 	})
 }
```

The refresh request saves the new token, as login does:

```diff lang="http" title="requests.http" collapse={1-23} collapse={40-72}
 ### 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
 Content-Type: application/json
 
 {
   "title": "Ship v1",
   "description": "The first release"
 }
 
 ### Create task with bad data
 POST http://localhost:3000/api/v1/tasks
 Content-Type: application/json
 
 {
   "title": "   ",
   "assigned_to": 99,
   "due_at": "2020-01-01"
 }
 
 ### Show task
 GET http://localhost:3000/api/v1/tasks/1
 
 ### List tasks
 GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
 
 ### List users
 GET http://localhost:3000/api/v1/users
 
 ### Show user
 GET http://localhost:3000/api/v1/users/2
 
 ### Hello
 GET http://localhost:3000/hello
```

Your last token is logged out, so log in again and set `TOKEN` first:

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

The new token comes back, shortened here:

```json title="POST /api/v1/refresh" wrap
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"
}
```

An expired token refreshes too. Start `air` with `JWT_TTL=1` again, log in, set `TOKEN`, and wait a minute:

```bash t1:expired=/"(authentication token has expired)"/
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
{"error":"authentication token has expired"}

$ curl -X POST http://localhost:3000/api/v1/refresh -H "Authorization: Bearer $TOKEN"
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
```

Then start plain `air` again.

TaskFlow now has accounts, and the token routes know who is calling. The task routes still answer anyone.

---

Previous: [08. Validation with Form Requests](https://devchalk.com/goravel/08-validation-form-requests.md)
Next: [10. Auth Middleware & Rate Limits](https://devchalk.com/goravel/10-auth-middleware.md)
Series: [Goravel](https://devchalk.com/goravel.md)
