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
git clone https://github.com/krishankumar01/goravel-taskflowcd goravel-taskflowgit checkout part-08cp .env.example .envgo run . artisan key:generatedocker compose up -dgo run . artisan migrate:fresh --seedgo install github.com/air-verse/air@latestairRegistering
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 successfullyFill it in, and delete the methods it doesn’t use:
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", }}lowerturnsBen@Example.comintoben@example.com. Filters run before rules, and Postgres compares text exactly, so without it the same person could sign up twice.uniquelooks the email up inusers.bailstops 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
rolefield, 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.
go run . artisan make:rule MaxBytes SUCCESS Rule created successfully SUCCESS Rule registered successfullypackage 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
72inmax_bytes:72arrives asoptions[0], and the message shows it as:option0. - The message doesn’t use
:value, which would put the password in the response. make:rulealso added the rule tobootstrap/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 successfullyReplace its Index with Register:
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 nilfunc (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().Maketurns 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.
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) })}### RegisterPOST http://localhost:3000/api/v1/registerContent-Type: application/json
{ "name": "Ben", "email": "ben@example.com", "password": "secret123"}
### Create taskPOST http://localhost:3000/api/v1/tasksContent-Type: application/json
29 collapsed lines
{ "title": "Ship v1", "description": "The first release"}
### Create task with bad dataPOST http://localhost:3000/api/v1/tasksContent-Type: application/json
{ "title": " ", "assigned_to": 99, "due_at": "2020-01-01"}
### Show taskGET http://localhost:3000/api/v1/tasks/1
### List tasksGET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
### List usersGET http://localhost:3000/api/v1/users
### Show userGET http://localhost:3000/api/v1/users/2
### HelloGET http://localhost:3000/helloSign 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"}'{ "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:
curl -X POST http://localhost:3000/api/v1/register \ -H "Content-Type: application/json" \ -d '{"name":"Ben","email":"Ben@Example.com","password":"secret123"}'{ "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:
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"}'{ "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:
go run . artisan jwt:secret SUCCESS Jwt Secret set successfullyAnyone 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:
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 answer500 Internal Server Errorwith an empty body. The reason,authentication secret is missing or required, is only instorage/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 successfullypackage 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", }}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:
- The query finds the user by email. When nobody has it,
Firstreturns no error anduser.IDstays0. Hash().Checkcompares the password with the stored hash. It runs before theuser.IDtest, so bcrypt runs either way.user.ID == 0then refuses an unknown email, even if someone sent the right password fornoUserHash.Auth().Loginsigns 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.
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.
4 collapsed lines
### RegisterPOST http://localhost:3000/api/v1/registerContent-Type: application/json
{ "name": "Ben", "email": "ben@example.com", "password": "secret123"}
### Log inPOST http://localhost:3000/api/v1/loginContent-Type: application/json
{ "email": "ben@example.com", "password": "secret123"}
> {% client.global.set("token", response.body.token);%}
### Create taskPOST http://localhost:3000/api/v1/tasksContent-Type: application/json
29 collapsed lines
{ "title": "Ship v1", "description": "The first release"}
### Create task with bad dataPOST http://localhost:3000/api/v1/tasksContent-Type: application/json
{ "title": " ", "assigned_to": 99, "due_at": "2020-01-01"}
### Show taskGET http://localhost:3000/api/v1/tasks/1
### List tasksGET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
### List usersGET http://localhost:3000/api/v1/users
### Show userGET http://localhost:3000/api/v1/users/2
### HelloGET http://localhost:3000/helloMoreIn 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"}'{ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiI1Iiwic3ViIjoidXNlciIsImV4cCI6MTc5Mjg1MjQ2NSwiaWF0IjoxNzkyODQ4ODY1fQ.A55FXego6oS5Usv9ae95rIN2J4vFqMR5W3oFz6AurEw"}With curl, paste your whole token into a variable for the next requests:
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 1A wrong password and an unknown email both get:
{ "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:
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.166348sWhat 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:
keyis the user’s id.subis the guard,user. Goravel puts the guard name there, where most systems put the user id.iatandexpare when the token was issued and when it expires, in Unix seconds. A client can readexpto 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?”:
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)}Parsetakes theAuthorizationheader and removesBeareritself. Its errors are fixed messages likeauthentication token has expired, so they are safe to send.Userloads the user whose id is inkey, with the request’s context.- If that user has been deleted,
Mesays 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.
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:
19 collapsed lines
### RegisterPOST http://localhost:3000/api/v1/registerContent-Type: application/json
{ "name": "Ben", "email": "ben@example.com", "password": "secret123"}
### Log inPOST http://localhost:3000/api/v1/loginContent-Type: application/json
{ "email": "ben@example.com", "password": "secret123"}
> {% client.global.set("token", response.body.token);%}
### MeGET http://localhost:3000/api/v1/meAuthorization: Bearer {{token}}
### Create taskPOST http://localhost:3000/api/v1/tasksContent-Type: application/json
29 collapsed lines
{ "title": "Ship v1", "description": "The first release"}
### Create task with bad dataPOST http://localhost:3000/api/v1/tasksContent-Type: application/json
{ "title": " ", "assigned_to": 99, "due_at": "2020-01-01"}
### Show taskGET http://localhost:3000/api/v1/tasks/1
### List tasksGET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
### List usersGET http://localhost:3000/api/v1/users
### Show userGET http://localhost:3000/api/v1/users/2
### HelloGET http://localhost:3000/hellocurl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"{ "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:
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:
JWT_TTL=1 aircurl 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:
Logout needs to know which token to disable, so it parses the header first:
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()}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) })}23 collapsed lines
### RegisterPOST http://localhost:3000/api/v1/registerContent-Type: application/json
{ "name": "Ben", "email": "ben@example.com", "password": "secret123"}
### Log inPOST http://localhost:3000/api/v1/loginContent-Type: application/json
{ "email": "ben@example.com", "password": "secret123"}
> {% client.global.set("token", response.body.token);%}
### MeGET http://localhost:3000/api/v1/meAuthorization: Bearer {{token}}
### Log outPOST http://localhost:3000/api/v1/logoutAuthorization: Bearer {{token}}
### Create taskPOST http://localhost:3000/api/v1/tasksContent-Type: application/json
29 collapsed lines
{ "title": "Ship v1", "description": "The first release"}
### Create task with bad dataPOST http://localhost:3000/api/v1/tasksContent-Type: application/json
{ "title": " ", "assigned_to": 99, "due_at": "2020-01-01"}
### Show taskGET http://localhost:3000/api/v1/tasks/1
### List tasksGET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
### List usersGET http://localhost:3000/api/v1/users
### Show userGET http://localhost:3000/api/v1/users/2
### HelloGET http://localhost:3000/helloStart 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 ContentMONITOR 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:
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:
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.
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:
23 collapsed lines
### RegisterPOST http://localhost:3000/api/v1/registerContent-Type: application/json
{ "name": "Ben", "email": "ben@example.com", "password": "secret123"}
### Log inPOST http://localhost:3000/api/v1/loginContent-Type: application/json
{ "email": "ben@example.com", "password": "secret123"}
> {% client.global.set("token", response.body.token);%}
### MeGET http://localhost:3000/api/v1/meAuthorization: Bearer {{token}}
### RefreshPOST http://localhost:3000/api/v1/refreshAuthorization: Bearer {{token}}
> {% client.global.set("token", response.body.token);%}
### Log outPOST http://localhost:3000/api/v1/logoutAuthorization: Bearer {{token}}
33 collapsed lines
### Create taskPOST http://localhost:3000/api/v1/tasksContent-Type: application/json
{ "title": "Ship v1", "description": "The first release"}
### Create task with bad dataPOST http://localhost:3000/api/v1/tasksContent-Type: application/json
{ "title": " ", "assigned_to": 99, "due_at": "2020-01-01"}
### Show taskGET http://localhost:3000/api/v1/tasks/1
### List tasksGET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
### List usersGET http://localhost:3000/api/v1/users
### Show userGET http://localhost:3000/api/v1/users/2
### HelloGET http://localhost:3000/helloYour 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"The new token comes back, shortened here:
{ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}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…"}Then start plain air again.
TaskFlow now has accounts, and the token routes know who is calling. The task routes still answer anyone.
git checkout part-09 See the changesBrowse the code ↗Download zip