DEV Community

Cover image for Goravel JWT authentication: register and log in
Krishan
Krishan

Posted on Edited on Originally published at devchalk.com

Goravel JWT authentication: register and log in

Part 9 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-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:

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
Enter fullscreen mode Exit fullscreen mode

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 does that, as in Validation with Form Requests:

$ go run . artisan make:request RegisterRequest
 SUCCESS  Request created successfully
Enter fullscreen mode Exit fullscreen mode

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

app/http/requests/register_request.go

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",
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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 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 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.

$ go run . artisan make:rule MaxBytes
 SUCCESS  Rule created successfully
 SUCCESS  Rule registered successfully
Enter fullscreen mode Exit fullscreen mode

app/rules/max_bytes.go

 package rules

 import (
    "context"
+   "fmt"
+   "strconv"

    "github.com/goravel/framework/contracts/validation"
 )
 // ... 9 lines not shown
 // 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."
 }
Enter fullscreen mode Exit fullscreen mode
  • 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:

$ go run . artisan make:controller AuthController
 SUCCESS  Controller created successfully
Enter fullscreen mode Exit fullscreen mode

Replace its Index with Register:

app/http/controllers/auth_controller.go

 package controllers

 import (
+   "taskflow/app/facades"
+   "taskflow/app/http/requests"
+   "taskflow/app/models"
+
    "github.com/goravel/framework/contracts/http"
 )

 // ... 9 lines not shown

-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)
 }
Enter fullscreen mode Exit fullscreen mode
  • 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.

routes/api.go

 // ... 9 lines not shown
 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)
    })
 }
Enter fullscreen mode Exit fullscreen mode

requests.http

+### 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

 # ... 29 lines not shown
Enter fullscreen mode Exit fullscreen mode

Sign Ben up:

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

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
}
Enter fullscreen mode Exit fullscreen mode

The answer is 201 Created, without the password, because of its json:"-" tag from 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:

[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"
Enter fullscreen mode Exit fullscreen mode

The same email in capitals is turned away by unique:

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

POST /api/v1/register

{
  "errors": {
    "email": {
      "unique": "The email has already been taken."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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

[2026-10-24 13:34:22.162] local.info: [0.863ms] [rows:1] SELECT count(*) FROM "users" WHERE "email" = 'ben@example.com'
Enter fullscreen mode Exit fullscreen mode

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

$ 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"}'
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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, 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:

$ go run . artisan jwt:secret
 SUCCESS  Jwt Secret set successfully
Enter fullscreen mode Exit fullscreen mode

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:

config/auth.go

// ... 17 lines not shown

        // ... 11 lines not shown
        "guards": map[string]any{
            "user": map[string]any{
                "driver":   "jwt",
                "provider": "user",
            },
        },

        // Supported: "orm"
        "providers": map[string]any{
            "user": map[string]any{
                "driver": "orm",
            },
        },
    })
}
Enter fullscreen mode Exit fullscreen mode

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

$ go run . artisan make:request LoginRequest
 SUCCESS  Request created successfully
Enter fullscreen mode Exit fullscreen mode

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",
    }
}
Enter fullscreen mode Exit fullscreen mode

app/http/controllers/auth_controller.go

 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
+})

 // ... 44 lines not shown
+
+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,
+   })
+}
Enter fullscreen mode Exit fullscreen mode

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

routes/api.go

 // ... 9 lines not shown
 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)
    })
 }
Enter fullscreen mode Exit fullscreen mode

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.

requests.http

 # ... 4 lines not shown
 {
   "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

 # ... 29 lines not shown
Enter fullscreen mode Exit fullscreen mode

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:

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

POST /api/v1/login

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiI1Iiwic3ViIjoidXNlciIsImV4cCI6MTc5Mjg1MjQ2NSwiaWF0IjoxNzkyODQ4ODY1fQ.A55FXego6oS5Usv9ae95rIN2J4vFqMR5W3oFz6AurEw"
}
Enter fullscreen mode Exit fullscreen mode

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

$ TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
Enter fullscreen mode Exit fullscreen mode

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

[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
Enter fullscreen mode Exit fullscreen mode

A wrong password and an unknown email both get:

POST /api/v1/login

{
  "error": "The email or password is wrong."
}
Enter fullscreen mode Exit fullscreen mode

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:

$ 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
Enter fullscreen mode Exit fullscreen mode

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.

Interactive tool on DevChalk: decode a token and check its signature.

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?":

app/http/controllers/auth_controller.go

 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"
 )

 // ... 99 lines not shown
+
+func (r *AuthController) Me(ctx http.Context) http.Response {
+   if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil {
+       return ctx.Response().Json(401, map[string]any{
+           "error": err.Error(),
+       })
+   }
+
+   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)
+}
Enter fullscreen mode Exit fullscreen mode
  • Parse 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.

routes/api.go

 // ... 9 lines not shown
 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)
    })
 }
Enter fullscreen mode Exit fullscreen mode

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

requests.http

 # ... 19 lines not shown
 > {%
   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

 # ... 29 lines not shown
Enter fullscreen mode Exit fullscreen mode
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

[2026-10-24 13:35:11.682] local.info: [2.067ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = '5'
Enter fullscreen mode Exit fullscreen mode

Bad and expired tokens

No header and a changed token get the same 401:

$ 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"}
Enter fullscreen mode Exit fullscreen mode

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:

$ JWT_TTL=1 air
Enter fullscreen mode Exit fullscreen mode
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
{"error":"authentication token has expired"}
Enter fullscreen mode Exit fullscreen mode

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 in the cache, Redis here, and Parse checks that mark before anything else. Step through a whole session to see where each request goes:

Interactive figure on DevChalk: 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:

app/http/controllers/auth_controller.go

 // ... 136 lines not shown
+
+func (r *AuthController) Logout(ctx http.Context) http.Response {
+   if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil {
+       return ctx.Response().Json(401, map[string]any{
+           "error": err.Error(),
+       })
+   }
+
+   if err := facades.Auth(ctx).Logout(); err != nil {
+       return ctx.Response().Json(500, map[string]any{
+           "error": "Could not log out.",
+       })
+   }
+
+   return ctx.Response().NoContent()
+}
Enter fullscreen mode Exit fullscreen mode

routes/api.go

 // ... 9 lines not shown
 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)
    })
 }
Enter fullscreen mode Exit fullscreen mode

requests.http

 # ... 23 lines not shown
 ### 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

 # ... 29 lines not shown
Enter fullscreen mode Exit fullscreen mode

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

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

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

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"
Enter fullscreen mode Exit fullscreen mode

The same token is refused now:

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

Refreshing a token

Refresh 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:

app/http/controllers/auth_controller.go

 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"
 )

 // ... 123 lines not shown
+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,
+   })
+}
+
 // ... 15 lines not shown
Enter fullscreen mode Exit fullscreen mode

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.

routes/api.go

 // ... 9 lines not shown
 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)
    })
 }
Enter fullscreen mode Exit fullscreen mode

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

requests.http

 # ... 23 lines not shown
 ### 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}}

 # ... 33 lines not shown
Enter fullscreen mode Exit fullscreen mode

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

$ curl -X POST http://localhost:3000/api/v1/refresh -H "Authorization: Bearer $TOKEN"
Enter fullscreen mode Exit fullscreen mode

The new token comes back, shortened here:

POST /api/v1/refresh

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"
}
Enter fullscreen mode Exit fullscreen mode

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

$ 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…"}
Enter fullscreen mode Exit fullscreen mode

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
Next: 10. Auth Middleware & Rate Limits
Series: Goravel

Top comments (0)