Backend (Go API)

Authentication

Grit ships with a complete JWT-based authentication system. It includes register, login, token refresh, logout, password reset, and role-based access control -- all pre-configured and ready to use.

Authentication Flow

Grit uses a dual-token JWT strategy: a short-lived access token for API requests and a long-lived refresh token for obtaining new access tokens without re-authenticating.

authentication flow

  Client                           Grit API
    |                                  |
    |  POST /api/auth/register         |
    |  { name, email, password }       |
    | -------------------------------->|
    |                                  |  Hash password (bcrypt)
    |                                  |  Create user in DB
    |                                  |  Generate access + refresh tokens
    |  { user, tokens }                |
    | <--------------------------------|
    |                                  |
    |  GET /api/posts                  |
    |  Authorization: Bearer <access>  |
    | -------------------------------->|
    |                                  |  Validate JWT
    |                                  |  Load user from DB
    |  { data: [...] }                 |
    | <--------------------------------|
    |                                  |
    |  --- access token expires ---    |
    |                                  |
    |  POST /api/auth/refresh          |
    |  { refresh_token }               |
    | -------------------------------->|
    |                                  |  Validate refresh token
    |                                  |  Generate new token pair
    |  { tokens }                      |
    | <--------------------------------|
    |                                  |

JWT Tokens

Grit generates two JWT tokens on login/register. Both are signed with HMAC-SHA256 using the JWT_SECRET environment variable.

TokenDefault ExpiryPurpose
access_token15 minutesSent with every API request in the Authorization header
refresh_token7 days (168h)Used to get a new access token when it expires

Configure token expiry via environment variables:

.env
JWT_SECRET=your-super-secret-key-at-least-32-chars
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=168h

Token Claims (JWT Payload)

Each token contains these claims:

services/auth.go
type Claims struct {
    UserID uint   `json:"user_id"`
    Email  string `json:"email"`
    Role   string `json:"role"`
    jwt.RegisteredClaims  // exp, iat, etc.
}

Auth Endpoints

All auth endpoints are mounted at /api/auth. Register, login, refresh, and forgot/reset-password are public. Me and logout require authentication.

MethodEndpointAuthDescription
POST/api/auth/registerNoCreate a new user account
POST/api/auth/loginNoAuthenticate and get tokens
POST/api/auth/refreshNoGet new tokens with refresh token
GET/api/auth/meYesGet current authenticated user
POST/api/auth/logoutYesInvalidate user session
POST/api/auth/forgot-passwordNoRequest a password reset link
POST/api/auth/reset-passwordNoReset password with token

Register

POST /api/auth/register
// Request
{
    "name": "John Doe",
    "email": "john@example.com",
    "password": "securepassword123"
}

// Response (201 Created)
{
    "data": {
        "user": {
            "id": 1,
            "name": "John Doe",
            "email": "john@example.com",
            "role": "user",
            "avatar": "",
            "active": true,
            "email_verified_at": null,
            "created_at": "2026-02-11T10:00:00Z",
            "updated_at": "2026-02-11T10:00:00Z"
        },
        "tokens": {
            "access_token": "eyJhbGciOiJIUzI1NiIs...",
            "refresh_token": "eyJhbGciOiJIUzI1NiIs...",
            "expires_at": 1707649200
        }
    },
    "message": "User registered successfully"
}

Login

POST /api/auth/login
// Request
{
    "email": "john@example.com",
    "password": "securepassword123"
}

// Response (200 OK)
{
    "data": {
        "user": { ... },
        "tokens": {
            "access_token": "eyJhbGciOiJIUzI1NiIs...",
            "refresh_token": "eyJhbGciOiJIUzI1NiIs...",
            "expires_at": 1707649200
        }
    },
    "message": "Logged in successfully"
}

// Error (401 Unauthorized)
{
    "error": {
        "code": "INVALID_CREDENTIALS",
        "message": "Invalid email or password"
    }
}

Refresh Token

POST /api/auth/refresh
// Request
{
    "refresh_token": "eyJhbGciOiJIUzI1NiIs..."
}

// Response (200 OK)
{
    "data": {
        "tokens": {
            "access_token": "eyJhbGciOiJIUzI1NiIs...",
            "refresh_token": "eyJhbGciOiJIUzI1NiIs...",
            "expires_at": 1707650100
        }
    },
    "message": "Token refreshed successfully"
}

Forgot Password

POST /api/auth/forgot-password
// Request
{
    "email": "john@example.com"
}

// Response (200 OK) -- always returns success for security
{
    "message": "If an account with that email exists, a password reset link has been sent"
}

The forgot-password endpoint always returns a success message regardless of whether the email exists. This prevents email enumeration attacks.

Reset Password

POST /api/auth/reset-password
// Request
{
    "token": "abc123def456...",
    "password": "newSecurePassword456"
}

// Response (200 OK)
{
    "message": "Password reset successfully"
}

Auth Middleware Usage

Apply the Auth middleware to any route group that requires authentication. See the Middleware page for the full implementation.

routes.go
// Protected routes -- any authenticated user
protected := r.Group("/api")
protected.Use(middleware.Auth(db, authService))
{
    protected.GET("/auth/me", authHandler.Me)
    protected.POST("/auth/logout", authHandler.Logout)
    protected.GET("/posts", postHandler.List)
}

// Admin routes -- admin role required
admin := r.Group("/api")
admin.Use(middleware.Auth(db, authService))
admin.Use(middleware.RequireRole("admin"))
{
    admin.GET("/users", userHandler.List)
    admin.DELETE("/users/:id", userHandler.Delete)
}

Role-Based Access Control

Grit defines three built-in roles. You can extend these by adding new constants to the User model.

RoleConstantAccess Level
adminmodels.RoleAdminFull access to all resources, user management, admin panel
editormodels.RoleEditorCan create and edit content, limited admin access
usermodels.RoleUserDefault role, can access own data only
models/user.go
// Built-in roles
const (
    RoleAdmin  = "admin"
    RoleEditor = "editor"
    RoleUser   = "user"
)

// Add custom roles:
const (
    RoleManager   = "manager"
    RoleModerator = "moderator"
)

Token Storage on the Frontend

The Next.js frontend stores tokens and includes them in API requests using an Axios interceptor. The recommended pattern:

apps/web/lib/api-client.ts
import axios from 'axios';

const api = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
});

// Attach access token to every request
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('access_token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Auto-refresh on 401
api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const refreshToken = localStorage.getItem('refresh_token');
        const { data } = await axios.post(
          `${api.defaults.baseURL}/api/auth/refresh`,
          { refresh_token: refreshToken },
        );

        const { access_token, refresh_token } = data.data.tokens;
        localStorage.setItem('access_token', access_token);
        localStorage.setItem('refresh_token', refresh_token);

        originalRequest.headers.Authorization = `Bearer ${access_token}`;
        return api(originalRequest);
      } catch {
        // Refresh failed -- redirect to login
        localStorage.removeItem('access_token');
        localStorage.removeItem('refresh_token');
        window.location.href = '/login';
      }
    }

    return Promise.reject(error);
  },
);

export default api;

Security note: Storing tokens in localStorage is acceptable for most applications. For higher security, consider usinghttpOnly cookies by modifying the login/refresh endpoints to set cookies instead of returning tokens in the JSON body.

Password Hashing

Passwords are hashed using bcrypt with the default cost factor (10). Hashing happens automatically via the GORM BeforeCreate hook on the User model. Passwords are never stored in plain text and are never returned in API responses (the Password field uses json:"-").

models/user.go
// Password field -- never included in JSON responses
Password string `gorm:"size:255;not null" json:"-"`

// Automatically hash on create
func (u *User) BeforeCreate(tx *gorm.DB) error {
    if u.Password != "" {
        hashedPassword, err := bcrypt.GenerateFromPassword(
            []byte(u.Password), bcrypt.DefaultCost,
        )
        if err != nil {
            return err
        }
        u.Password = string(hashedPassword)
    }
    return nil
}

// Verify password during login
func (u *User) CheckPassword(password string) bool {
    err := bcrypt.CompareHashAndPassword(
        []byte(u.Password), []byte(password),
    )
    return err == nil
}

Token Generation

The AuthService handles all token operations. It uses thegolang-jwt/jwt/v5 library with HMAC-SHA256 signing.

services/auth.go
// GenerateTokenPair creates access + refresh tokens.
func (s *AuthService) GenerateTokenPair(
    userID uint, email, role string,
) (*TokenPair, error) {
    accessToken, expiresAt, err := s.generateToken(
        userID, email, role, s.AccessExpiry,
    )
    if err != nil {
        return nil, fmt.Errorf("generating access token: %w", err)
    }

    refreshToken, _, err := s.generateToken(
        userID, email, role, s.RefreshExpiry,
    )
    if err != nil {
        return nil, fmt.Errorf("generating refresh token: %w", err)
    }

    return &TokenPair{
        AccessToken:  accessToken,
        RefreshToken: refreshToken,
        ExpiresAt:    expiresAt,
    }, nil
}

func (s *AuthService) generateToken(
    userID uint, email, role string, expiry time.Duration,
) (string, int64, error) {
    expiresAt := time.Now().Add(expiry)

    claims := &Claims{
        UserID: userID,
        Email:  email,
        Role:   role,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(expiresAt),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    tokenString, err := token.SignedString([]byte(s.Secret))
    if err != nil {
        return "", 0, err
    }

    return tokenString, expiresAt.Unix(), nil
}

Password Reset Tokens

Password reset tokens are cryptographically random 32-byte hex strings. They are generated using Go's crypto/rand package, which is secure for this purpose.

services/auth.go
// GenerateResetToken creates a random hex token for password resets.
func GenerateResetToken() (string, error) {
    bytes := make([]byte, 32)
    if _, err := rand.Read(bytes); err != nil {
        return "", fmt.Errorf("generating reset token: %w", err)
    }
    return hex.EncodeToString(bytes), nil
}

// Output example: "a3f4b2c1e5d6f7890123456789abcdef..."
// (64 hex characters = 32 bytes of randomness)

Auth Configuration

All authentication settings are configured via environment variables:

.env
# Required
JWT_SECRET=change-this-to-a-long-random-string

# Optional (defaults shown)
JWT_ACCESS_EXPIRY=15m      # Go duration format
JWT_REFRESH_EXPIRY=168h    # 7 days

Important: The JWT_SECRET environment variable is required. The server will not start without it. Use a random string of at least 32 characters in production.