Authentication
Grit ships with a complete JWT-based authentication system. It includes register, login, token refresh, logout, password reset, role-based access control, and two-factor authentication (TOTP) with backup codes and trusted devices -- 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.
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.
| Token | Default Expiry | Purpose |
|---|---|---|
| access_token | 15 minutes | Sent with every API request in the Authorization header |
| refresh_token | 7 days (168h) | Used to get a new access token when it expires |
Configure token expiry via environment variables:
JWT_SECRET=your-super-secret-key-at-least-32-charsJWT_ACCESS_EXPIRY=15mJWT_REFRESH_EXPIRY=168h
Token Claims (JWT Payload)
Each token contains these claims:
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.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /api/auth/register | No | Create a new user account |
| POST | /api/auth/login | No | Authenticate and get tokens |
| POST | /api/auth/refresh | No | Get new tokens with refresh token |
| GET | /api/auth/me | Yes | Get current authenticated user |
| POST | /api/auth/logout | Yes | Invalidate user session |
| POST | /api/auth/forgot-password | No | Request a password reset link |
| POST | /api/auth/reset-password | No | Reset password with token |
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
// 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
// Request{"refresh_token": "eyJhbGciOiJIUzI1NiIs..."}// Response (200 OK){"data": {"tokens": {"access_token": "eyJhbGciOiJIUzI1NiIs...","refresh_token": "eyJhbGciOiJIUzI1NiIs...","expires_at": 1707650100}},"message": "Token refreshed successfully"}
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
// 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.
// Protected routes -- any authenticated userprotected := 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 requiredadmin := 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.
| Role | Constant | Access Level |
|---|---|---|
| admin | models.RoleAdmin | Full access to all resources, user management, admin panel |
| editor | models.RoleEditor | Can create and edit content, limited admin access |
| user | models.RoleUser | Default role, can access own data only |
// Built-in rolesconst (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:
import axios from 'axios';const api = axios.create({baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',});// Attach access token to every requestapi.interceptors.request.use((config) => {const token = localStorage.getItem('access_token');if (token) {config.headers.Authorization = `Bearer ${token}`;}return config;});// Auto-refresh on 401api.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 loginlocalStorage.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:"-").
// Password field -- never included in JSON responsesPassword string `gorm:"size:255;not null" json:"-"`// Automatically hash on createfunc (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 loginfunc (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.
// 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.
// 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)
Two-Factor Authentication (TOTP)
Every Grit project includes a complete two-factor authentication system using TOTP (Time-based One-Time Passwords). It works with any authenticator app: Google Authenticator, Authy, 1Password, Bitwarden, etc.
How It Works
Client Grit API| || POST /api/auth/login || { email, password } || -------------------------------->|| | Validate password ✓| | Check: TOTP enabled?| | Check: Trusted device cookie?| || If TOTP required: || { totp_required, pending_token }|| <--------------------------------|| || POST /api/auth/totp/verify || { pending_token, code, trust } || -------------------------------->|| | Validate TOTP code ✓| | (Optional) Set trusted device| { user, tokens } || <--------------------------------|
If the user has 2FA enabled and no trusted device cookie, the login endpoint returns a short-lived pending_token (5 minutes) instead of JWT tokens. The client then redirects to a TOTP verification page.
TOTP Endpoints
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
POST | /api/auth/totp/setup | JWT | Generate secret + QR code URI |
POST | /api/auth/totp/enable | JWT | Verify initial code, activate 2FA, get backup codes |
POST | /api/auth/totp/verify | Public | Exchange pending token + TOTP code for JWT |
POST | /api/auth/totp/backup-codes/verify | Public | Use backup code during login |
POST | /api/auth/totp/disable | JWT | Turn off 2FA (requires password) |
GET | /api/auth/totp/status | JWT | Check 2FA status, backup codes remaining |
POST | /api/auth/totp/backup-codes | JWT | Regenerate backup codes |
DELETE | /api/auth/totp/trusted-devices | JWT | Revoke all trusted devices |
Enabling 2FA (User Flow)
// Step 1: Get the secret and QR code URIconst { data } = await api.post('/api/auth/totp/setup')// data.secret = "JBSWY3DPEHPK3PXP..."// data.uri = "otpauth://totp/MyApp:user@email.com?secret=..."// → Show QR code to user (use a QR library to render data.uri)// Step 2: User scans QR code, enters the 6-digit code from their appconst { data: result } = await api.post('/api/auth/totp/enable', {secret: data.secret,code: '123456' // from authenticator app})// result.enabled = true// result.backup_codes = ["A1B2C3D4", "E5F6G7H8", ...]// → Show backup codes to user (they must save these!)
Login with 2FA (Client Flow)
// Step 1: Normal loginconst { data } = await api.post('/api/auth/login', { email, password })if (data.totp_required) {// Step 2: Redirect to TOTP verification page// Store the pending token temporarilyconst pendingToken = data.pending_token// Step 3: User enters 6-digit code from authenticator appconst { data: result } = await api.post('/api/auth/totp/verify', {pending_token: pendingToken,code: '123456',trust_device: true // optional: remember this device for 30 days})// result.user = { ... }// result.tokens = { access_token, refresh_token }} else {// No 2FA — normal login, tokens already returned// data.user = { ... }// data.tokens = { access_token, refresh_token }}
Backup Codes
When 2FA is enabled, 10 one-time-use backup codes are generated. Each code is individually bcrypt-hashed before storage. When a user enters a backup code during login, the used code is permanently removed from the database.
// During login, if user lost their authenticator app:const { data } = await api.post('/api/auth/totp/backup-codes/verify', {pending_token: pendingToken,code: 'A1B2C3D4', // one of the saved backup codestrust_device: false})// data.backup_codes_remaining = 9 (one code was consumed)
Trusted Devices
When trust_device: true is sent during TOTP verification, an HttpOnly cookie (totp_trusted) is set with a random token. The SHA-256 hash of this token is stored in the database with the user's IP and user agent. Trusted devices last 30 days with sliding expiry — each successful login refreshes the timer.
Users can revoke all trusted devices:
await api.delete('/api/auth/totp/trusted-devices')// All trusted device cookies are now invalid
Implementation Details
- Algorithm: HMAC-SHA1 (RFC 6238 / RFC 4226)
- Code length: 6 digits
- Period: 30 seconds
- Clock skew: ±1 window tolerance (90 second total window)
- Secret: 20 random bytes, base32-encoded (no padding)
- Pending tokens: 32 random bytes, hex-encoded, SHA-256 hashed for DB, expires in 5 minutes
- Backup codes: 8-character hex codes, individually bcrypt-hashed, one-time use
- Trusted device tokens: 32 random bytes, SHA-256 hashed, 30-day sliding expiry
- Dependencies: Zero external — uses only Go standard library +
golang.org/x/crypto/bcrypt
Auth Configuration
All authentication settings are configured via environment variables:
# RequiredJWT_SECRET=change-this-to-a-long-random-string# Optional (defaults shown)JWT_ACCESS_EXPIRY=15m # Go duration formatJWT_REFRESH_EXPIRY=168h # 7 days# TOTP (Two-Factor Authentication)TOTP_ISSUER=MyApp # App name shown in authenticator apps (defaults to APP_NAME)
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.