
Vue.js + Go with Echo v5 series: Part 1: environment and first API · Part 2: Vue frontend with Vite · Part 3: tasks REST API · Part 4: login with session and cookie · Part 5: embed, single binary and Docker
Complete code: all files from the series are in the gist vue-go-echo-v5, with a README that shows which folder each one goes in.
The tasks API of part 3 is open to everyone. In this part it gets login: password stored as a bcrypt hash, session in a cookie HttpOnly, a Echo v5 middleware that blocks private routes and, in Vue, the login screen with route protection in Vue Router. It's a simple design, but with the right security choices for a small application.
Many tutorials store a JWT in localStorage and send it on every request. It works, but any JavaScript on the page can read that token, and an XSS becomes session theft. Since here the frontend and the API are on the same origin (Vite proxy in development, single binary in production), we can use the older and safer mechanism:
- Go generates a random token and stores it in a cookie
HttpOnly, which JavaScript cannot read; - the browser sends the cookie automatically on every
fetch; SameSite=Strictprevents another site from triggering authenticated requests on behalf of the user;- logout deletes the session on the server: the token becomes invalid immediately, which a JWT without a revocation list does not do.
The application has a single user, defined by environment variables. With multiple users, what changes is just where the password hash comes from: from a table in the database.
The authentication backend
Install the bcrypt package:
go get golang.org/x/crypto/bcrypt
Create internal/api/auth.go. First the structure and the constructor, which only stores the password hash, never the plaintext:
package api
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"net/http"
"sync"
"time"
"github.com/labstack/echo/v5"
"golang.org/x/crypto/bcrypt"
)
const (
cookieName = "sessao"
sessionTTL = 8 * time.Hour
)
// Auth guarda o usuário único da aplicação e as sessões abertas.
type Auth struct {
user string
passHash []byte
mu sync.Mutex
sessions map[string]session
}
type session struct {
user string
expires time.Time
}
// NewAuth recebe o usuário e a senha em texto e guarda só o hash bcrypt.
func NewAuth(user, password string) (*Auth, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
return &Auth{user: user, passHash: hash, sessions: map[string]session{}}, nil
}
func newToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
The session token has 32 bytes of crypto/rand: unpredictable, unlike a counter or math/rand.
Login
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// Login confere usuário e senha e cria a sessão em um cookie HttpOnly.
func (a *Auth) Login(c *echo.Context) error {
var req loginRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "JSON inválido")
}
userOK := subtle.ConstantTimeCompare([]byte(req.Username), []byte(a.user)) == 1
passOK := bcrypt.CompareHashAndPassword(a.passHash, []byte(req.Password)) == nil
if !userOK || !passOK {
return echo.NewHTTPError(http.StatusUnauthorized, "usuário ou senha inválidos")
}
token, err := newToken()
if err != nil {
return err
}
a.mu.Lock()
a.sessions[token] = session{user: a.user, expires: time.Now().Add(sessionTTL)}
a.mu.Unlock()
c.SetCookie(&http.Cookie{
Name: cookieName,
Value: token,
Path: "/",
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
Secure: c.Request().TLS != nil || c.Request().Header.Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteStrictMode,
})
return c.JSON(http.StatusOK, map[string]string{"username": a.user})
}
Points worth attention:
- bcrypt runs even when the recipient is wrong, and the name comparison is in constant time. So the response time does not reveal whether the user exists;
- the error message is the same for wrong username and wrong password;
Secureis enabled when the connection is HTTPS, directly or behind a proxy that sendsX-Forwarded-Proto: https(part 5). Inhttp://localhostit is disabled, otherwise the browser would discard the cookie.
Logout, current user, and the middleware
// Logout apaga a sessão no servidor e o cookie no navegador.
func (a *Auth) Logout(c *echo.Context) error {
if ck, err := c.Cookie(cookieName); err == nil {
a.mu.Lock()
delete(a.sessions, ck.Value)
a.mu.Unlock()
}
c.SetCookie(&http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
return c.NoContent(http.StatusNoContent)
}
// Me devolve o usuário da sessão atual.
func (a *Auth) Me(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]any{"username": c.Get("user")})
}
// Required é o middleware que bloqueia as rotas sem sessão válida.
func (a *Auth) Required(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
ck, err := c.Cookie(cookieName)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "faça login")
}
a.mu.Lock()
s, ok := a.sessions[ck.Value]
if ok && time.Now().After(s.expires) {
delete(a.sessions, ck.Value)
ok = false
}
a.mu.Unlock()
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "sessão expirada")
}
c.Set("user", s.user)
return next(c)
}
}
A middleware in Echo is a function that receives the next handler and returns another. The Required blocks the request with 401 or, if the session is valid, stores the user in the context with c.Set and proceeds to the handler, which reads the value with c.Get.
Protect the routes
Update internal/api/routes.go. Private routes receive auth.Required as the last argument, the middleware at the route level:
// Register liga as rotas em /api.
func Register(e *echo.Echo, auth *Auth, tasks *Tasks) {
g := e.Group("/api")
// Rotas públicas
g.GET("/health", func(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
g.POST("/login", auth.Login)
g.POST("/logout", auth.Logout)
// Rotas que exigem sessão: o middleware vai como último argumento
g.GET("/me", auth.Me, auth.Required)
g.GET("/tasks", tasks.List, auth.Required)
g.POST("/tasks", tasks.Create, auth.Required)
g.PATCH("/tasks/:id", tasks.Update, auth.Required)
g.DELETE("/tasks/:id", tasks.Delete, auth.Required)
}
Why not a sub-group g.Group("", auth.Required)? In our test, the sub-group with empty prefix and middleware also caught non-existent routes from /api: a wrong URL responded 401 instead of 404. With the middleware per route, the non-existent route keeps returning 404 and the protected one returns 401, and the reading is explicit.
On main.go, the password comes from the variable APP_PASSWORD, with no default value, and the server refuses to come up without it:
func main() {
user := getenv("APP_USER", "admin")
pass := os.Getenv("APP_PASSWORD")
if pass == "" {
slog.Error("defina APP_PASSWORD")
os.Exit(1)
}
auth, err := api.NewAuth(user, pass)
if err != nil {
slog.Error("hash da senha", "error", err)
os.Exit(1)
}
e := echo.New()
e.Use(middleware.RequestLogger())
e.Use(middleware.Recover())
api.Register(e, auth, api.NewTasks())
if err := e.Start(getenv("APP_ADDR", "127.0.0.1:8080")); err != nil {
slog.Error("servidor", "error", err)
}
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
Add "os" to the imports. Test the full flow with curl, saving the cookie to a file:
APP_PASSWORD=segredo123 go run .
B=http://127.0.0.1:8080; J='Content-Type: application/json'
curl -s -o /dev/null -w 'tasks sem login: %{http_code}\n' $B/api/tasks
curl -s -H "$J" -d '{"username":"admin","password":"errada"}' $B/api/login; echo
curl -s -c ck.txt -H "$J" -d '{"username":"admin","password":"segredo123"}' $B/api/login; echo
curl -s -b ck.txt $B/api/me; echo
curl -s -b ck.txt -X POST -o /dev/null -w 'logout: %{http_code}\n' $B/api/logout
curl -s -b ck.txt -o /dev/null -w 'me após logout: %{http_code}\n' $B/api/me
tasks sem login: 401
{"message":"usuário ou senha inválidos"}
{"username":"admin"}
{"username":"admin"}
logout: 204
me após logout: 401
The last line shows the advantage of the session on the server: the same cookie, resent after logout, is no longer worth anything.
The login screen in Vue
Replace web/src/views/LoginView.vue:
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api'
const router = useRouter()
const route = useRoute()
const username = ref('admin')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function login() {
error.value = ''
loading.value = true
try {
await api('POST', '/login', { username: username.value, password: password.value })
router.push(route.query.next || '/tarefas')
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
</script>
<template>
<form class="card" @submit.prevent="login">
<h1>Entrar</h1>
<label>Usuário <input v-model="username" autocomplete="username" required /></label>
<label>Senha <input v-model="password" type="password" autocomplete="current-password" required /></label>
<p v-if="error" class="error">{{ error }}</p>
<button :disabled="loading">{{ loading ? 'Entrando...' : 'Entrar' }}</button>
</form>
</template>
No token goes through JavaScript: the login only needs to know if it worked. The one who keeps the session is the browser.

Route protection in Vue Router
The guard beforeEach we wrote in part 2 now works: when opening a route with meta.auth, it calls /api/me; if it receives 401, it redirects to /login?next=/tarefas, and after login the user returns to where they wanted to go. Restore the meta: { auth: true } and the line in /me in TasksView, if you removed them to test.
Remember that this is UI convenience. The one protecting the data is Go: even if someone bypasses the Vue guard, each API route still requires the session.
With go run . and npm run dev running, open http://localhost:5173/tarefas. In our test with an automated browser, the entire flow passed:
- redirected to the login;
- rejected the wrong password;
- logged in;
- created, checked off, and removed tasks;
- maintained the session on page reload;
- after logging out, it started requiring login again.
What is still missing for production
- In-memory sessions are lost on restart and don't work with multiple instances. To address this, store them in a database or Redis;
- attempt limit: Echo has the middleware
RateLimiter; apply it to the login route; - HTTPS is mandatory outside of
localhost, otherwise the cookie travels in clear text. In part 5 we put Nginx in front.
Files for this part in the gist
Each file opens directly in the gist of the series. The gist brings the final version of the project: main.go and routes.go they also get login in part 4 and the embedded frontend in part 5.
auth.go(ininternal/api/auth.go)routes.go(ininternal/api/routes.go)main.goLoginView.vue(inweb/src/views/LoginView.vue)
Next step
The application is complete, but it still runs as two processes. In part 5 we embed Vue into the Go binary with go:embed, we generate a single executable, compile everything with Docker and deploy with systemd and Nginx.
Vue.js + Go with Echo v5 series: Part 1: environment and first API · Part 2: Vue frontend with Vite · Part 3: tasks REST API · Part 4: login with session and cookie · Part 5: embed, single binary and Docker