JWT auth

Login, refresh, middleware.

9 minmedium

Grit ships JWT auth out of the box: login, register, refresh, logout, and a middleware that validates tokens on protected routes. This lesson walks how it's wired and the two extension points you'll actually touch.

The flow

POST /api/auth/register {email, password} → 201 + {access_token, refresh_token}
POST /api/auth/login {email, password} → 200 + {access_token, refresh_token}
POST /api/auth/refresh {refresh_token} → 200 + {access_token}
POST /api/auth/logout Authorization header → 204
GET /api/auth/me Authorization header → 200 + {user}

Two tokens: a 15-minute access token sent on every request, and a 7-day refresh token used to get new access tokens without re-prompting login.

The access token shape

// Decoded JWT payload
{
"sub": "9b4d-...", // user UUID
"role": "user", // role for RBAC (covered in lesson 3.4)
"exp": 1730000000, // expiry timestamp
"iat": 1729000000
}

Signed with JWT_SECRET from .env using HS256. Grit's middleware pins the algorithm — alg:none attacks are impossible.

The Auth middleware

apps/api/internal/routes/routes.go (excerpt)
api := r.Group("/api")
// Public — no auth required
auth := api.Group("/auth")
{
auth.POST("/login", authHandler.Login)
auth.POST("/register", authHandler.Register)
auth.POST("/refresh", authHandler.Refresh)
}
// Protected — Auth middleware applied
protected := api.Group("")
protected.Use(middleware.Auth(authService))
{
protected.GET("/auth/me", authHandler.Me)
protected.POST("/auth/logout", authHandler.Logout)
protected.GET("/users", userHandler.List)
// â€Ļ
}

The middleware reads Authorization: Bearer <token>, verifies signature + expiry, and puts the user ID + role on the request context. Handlers grab it via:

userID, _ := c.Get("user_id")
role, _ := c.Get("role")

What to actually extend

Two places you'll touch:

  1. What gets returned with /auth/me — add fields to the response in authHandler.Me. Avatar URL, default tenant, feature flags.
  2. Custom claims — add fields to the JWT payload (e.g., tenant_id for multi-tenancy). Edit authService.GenerateToken + the verifier.
JWT_SECRET must be at least 32 characters. Grit's config loader refuses to boot if it's shorter, and v3.25 auto-generates a 64-char hex secret on scaffold. Don't reuse it across environments — staging and prod should have different secrets.

Refresh-token rotation

Each successful refresh issues a NEW refresh token and invalidates the old one (stored in a small DB table). This protects against stolen refresh tokens — a thief who uses the token rotates it, then the legitimate client's next refresh fails, and you know there's an incident.

Quick check

A user signs in, gets a 15-minute access token. After 14 minutes, what's the standard flow?

Try it

Try the full auth flow with curl:

Terminal
# 1. Register
$curl -X POST http://localhost:8080/api/auth/register \
$ -H 'Content-Type: application/json' \
$ -d '{"email":"alex@example.com","password":"alexsecret123","name":"Alex"}'
# 2. Login (or save the access_token from register response)
$curl -X POST http://localhost:8080/api/auth/login \
$ -H 'Content-Type: application/json' \
$ -d '{"email":"alex@example.com","password":"alexsecret123"}'
# 3. Hit /me with the token
$curl http://localhost:8080/api/auth/me \
$ -H 'Authorization: Bearer YOUR_ACCESS_TOKEN'

Paste the /me response in notes.md.

What's next

Email + password is one path. Most users prefer OAuth2 — Sign in with Google / GitHub. Next lesson wires both in 10 minutes.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub