DevChalk

Register & Login with JWT

Krishan Kumar · · Updated · 13 min See the changes ↗

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.

CodeStart from the Validation with Form Requests code
Terminal window
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 does that, as in Validation with Form Requests:

Terminal window
go run . artisan make:request RegisterRequest
SUCCESS Request created successfully

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",
}
}
  • 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:

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

Terminal window
go run . artisan make:rule MaxBytes
SUCCESS Rule created successfully
SUCCESS Rule registered successfully
app/rules/max_bytes.go
package rules
import (
"context"
"fmt"
"strconv"
"github.com/goravel/framework/contracts/validation"
)
9 collapsed lines
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:

Terminal window
go run . artisan make:controller AuthController
SUCCESS Controller created successfully

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

routes/api.go
9 collapsed lines
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)
})
}
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 collapsed lines
{
"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:

Terminal window
curl -X POST http://localhost:3000/api/v1/register \
-H "Content-Type: application/json" \
-d '{"name":"Ben","email":"ben@example.com","password":"secret123"}'
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. 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"

The same email in capitals is turned away by unique:

Terminal window
curl -X POST http://localhost:3000/api/v1/register \
-H "Content-Type: application/json" \
-d '{"name":"Ben","email":"Ben@Example.com","password":"secret123"}'
POST /api/v1/register
{
"errors": {
"email": {
"unique": "The email has already been taken."
}
}
}

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'

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

Terminal window
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"}'
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, 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:

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

config/auth.go
17 collapsed lines
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",
},
11 collapsed lines
// 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 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:

Terminal window
go run . artisan make:request LoginRequest
SUCCESS Request created successfully
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",
}
}
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 collapsed lines
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 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 collapsed lines
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.

requests.http
4 collapsed lines
### Register
POST http://localhost:3000/api/v1/register
Content-Type: application/json
{
"name": "Ben",
"email": "ben@example.com",
"password": "secret123"
}
### Log in
POST http://localhost:3000/api/v1/login
Content-Type: application/json
{
"email": "ben@example.com",
"password": "secret123"
}
> {%
client.global.set("token", response.body.token);
%}
### Create task
POST http://localhost:3000/api/v1/tasks
Content-Type: application/json
29 collapsed lines
{
"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
MoreIn 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:

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

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

Terminal window
TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…

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

A wrong password and an unknown email both get:

POST /api/v1/login
{
"error": "The email or password is wrong."
}
MoreTiming 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:

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

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

requests.http
19 collapsed lines
### Register
POST http://localhost:3000/api/v1/register
Content-Type: application/json
{
"name": "Ben",
"email": "ben@example.com",
"password": "secret123"
}
### Log in
POST http://localhost:3000/api/v1/login
Content-Type: application/json
{
"email": "ben@example.com",
"password": "secret123"
}
> {%
client.global.set("token", response.body.token);
%}
### Me
GET http://localhost:3000/api/v1/me
Authorization: Bearer {{token}}
### Create task
POST http://localhost:3000/api/v1/tasks
Content-Type: application/json
29 collapsed lines
{
"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
Terminal window
curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
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:

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

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

Terminal window
JWT_TTL=1 air
Terminal window
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 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.

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

app/http/controllers/auth_controller.go
136 collapsed lines
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()
}
routes/api.go
9 collapsed lines
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)
})
}
requests.http
23 collapsed lines
### Register
POST http://localhost:3000/api/v1/register
Content-Type: application/json
{
"name": "Ben",
"email": "ben@example.com",
"password": "secret123"
}
### Log in
POST http://localhost:3000/api/v1/login
Content-Type: application/json
{
"email": "ben@example.com",
"password": "secret123"
}
> {%
client.global.set("token", response.body.token);
%}
### Me
GET http://localhost:3000/api/v1/me
Authorization: Bearer {{token}}
### 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 collapsed lines
{
"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:

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

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:

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

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

routes/api.go
9 collapsed lines
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:

requests.http
23 collapsed lines
### Register
POST http://localhost:3000/api/v1/register
Content-Type: application/json
{
"name": "Ben",
"email": "ben@example.com",
"password": "secret123"
}
### Log in
POST http://localhost:3000/api/v1/login
Content-Type: application/json
{
"email": "ben@example.com",
"password": "secret123"
}
> {%
client.global.set("token", response.body.token);
%}
### Me
GET http://localhost:3000/api/v1/me
Authorization: Bearer {{token}}
### Refresh
POST http://localhost:3000/api/v1/refresh
Authorization: Bearer {{token}}
> {%
client.global.set("token", response.body.token);
%}
### Log out
POST http://localhost:3000/api/v1/logout
Authorization: Bearer {{token}}
33 collapsed lines
### 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:

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

The new token comes back, shortened here:

POST /api/v1/refresh
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"
}

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

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