Backend (Go API)

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.

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

Do not store tokens in localStorage for the web client. Anything readable from JavaScript is reachable by any XSS vector — a compromised npm dependency, a stored XSS bug in a comment field, or a browser extension. Tokens in localStorage are persistent, unscoped, and exfiltrate-able with a single line of script. The OWASP guidance (and ours) is to put auth cookies out of JavaScript's reach.

Pick the storage model that matches your client:

ClientToken storageWhy
Web (Next.js)httpOnly, Secure, SameSite=Lax cookies set by the APIXSS cannot read them; the browser attaches them automatically.
Mobile (Expo)expo-secure-store (iOS Keychain / Android Keystore)Hardware-backed, not readable by other apps or React Native bridges.
Desktop (Wails)OS keychain via Go binding (keyring)Same threat model as mobile; never the renderer.

Web: cookies set by the API

This is on by default. Every Grit project scaffolded with v3.25.3+ already sets grit_access + grit_refresh on Login / Register / Refresh / TOTP-verify and clears them on Logout. The middleware.Auth chain reads cookies first and falls back to the Authorization header for native bearer clients. You don't have to wire any of this yourself.

The API sets two cookies on login/register/refresh: grit_access (short-lived) and grit_refresh (long-lived, scoped to /api/auth). Both are HttpOnly so JavaScript cannot read them, Secure on HTTPS so they only travel over TLS, and SameSite=Lax so the CSRF surface is limited to top-level navigations.

Helpers live on the auth service for use in your own handlers:

internal/services/auth.go (already in scaffold)
// SetAuthCookies writes the token pair as HttpOnly cookies.
// Called from Register / Login / Refresh / TOTP verify.
func (s *AuthService) SetAuthCookies(c *gin.Context, pair *TokenPair) { ... }
// ClearAuthCookies expires both cookies. Called from Logout.
func (s *AuthService) ClearAuthCookies(c *gin.Context) { ... }

The auth middleware reads cookies first, then the Authorization header — samemiddleware.Auth(db, authService) covers both flows:

internal/middleware/auth.go (already in scaffold)
token := ""
if cookieValue, err := c.Cookie("grit_access"); err == nil && cookieValue != "" {
token = cookieValue
} else if authHeader := c.GetHeader("Authorization"); authHeader != "" {
// Bearer fallback for native mobile / desktop clients
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) == 2 && parts[0] == "Bearer" {
token = parts[1]
}
}

The Next.js client doesn't touch tokens at all — the browser handles them. Use credentials: 'include' on every request:

apps/web/lib/api-client.ts
import axios from 'axios'
const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
withCredentials: true, // <- the browser sends grit_access / grit_refresh automatically
})
// Auto-refresh on 401 — note: no token reading, no localStorage.
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
try {
// The browser sends grit_refresh; the API sets a new grit_access cookie.
await api.post('/api/auth/refresh')
return api(originalRequest)
} catch {
window.location.href = '/login'
}
}
return Promise.reject(error)
},
)
export default api

CSRF is on by default for the cookie flow. Cookie-auth APIs are vulnerable to CSRF (a malicious site forging a state-changing request from a logged-in user). The scaffolded middleware.AutoCSRF() is wired globally and enforces a double-submit CSRF token only when the request carries the grit_access cookie. Bearer-token requests (mobile / desktop) pass through with no header required — they aren't CSRF-vulnerable because browsers never auto-send a Bearer header across origins.

The SPA reads the token from the grit_csrf cookie (which is not HttpOnly, so JS can read it) and echoes it back in the X-CSRF-Token header. With Axios:

apps/web/lib/api-client.ts
import axios from 'axios'
const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
withCredentials: true,
})
// Echo the CSRF token from grit_csrf cookie into the X-CSRF-Token header.
// The middleware accepts this on every cookie-authenticated mutation.
api.interceptors.request.use((config) => {
const m = document.cookie.match(/(?:^|; )grit_csrf=([^;]+)/)
if (m) config.headers['X-CSRF-Token'] = decodeURIComponent(m[1])
return config
})
export default api

The first GET request (any GET) seeds the cookie; subsequent mutations carry the header. No bootstrap endpoint needed.

Mobile + Desktop: bearer header from secure store

Native clients can't use HttpOnly cookies cleanly across all platforms. Use the secure OS-backed store and the Authorization: Bearer header path:

apps/mobile/lib/auth-store.ts
import * as SecureStore from 'expo-secure-store'
const ACCESS = 'grit_access'
const REFRESH = 'grit_refresh'
export const saveTokens = async (access: string, refresh: string) => {
await SecureStore.setItemAsync(ACCESS, access)
await SecureStore.setItemAsync(REFRESH, refresh)
}
export const loadTokens = async () => ({
access: await SecureStore.getItemAsync(ACCESS),
refresh: await SecureStore.getItemAsync(REFRESH),
})
export const clearTokens = async () => {
await SecureStore.deleteItemAsync(ACCESS)
await SecureStore.deleteItemAsync(REFRESH)
}

The Authorization header path on the API stays for these clients. Desktop (Wails) uses an equivalent OS-keychain binding from Go and exposessaveTokens / loadTokens to the React frontend via Wails bindings.

Summary — never use localStorage for tokens

  • Web: HttpOnly cookies (default-on). No JS touches the access token. CSRF is auto-enforced via AutoCSRF().
  • Mobile: expo-secure-store + bearer header.
  • Desktop: OS keychain + bearer header.
  • Never: localStorage / sessionStorage for auth tokens.

Defence-in-depth — what else is on by default

  • SecurityHeaders middleware — strict CSP (no inline script), X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy strict-origin-when-cross-origin, Permissions-Policy locking down camera / mic / geolocation / payments / USB, COOP + CORP for Spectre isolation, and HSTS on HTTPS. Globally applied.
  • Sentinel AuthShield — brute-force lockout on /api/auth/login with progressive backoff. Default-on whenever the Sentinel suite is enabled (which it is by default in fresh scaffolds).
  • Sentinel rate limiting — 5 requests / 15 minutes per IP on/api/auth/login and 3 / 15 min on /api/auth/register in production. Dev gets relaxed limits so testing doesn't lock you out.
  • WAF — Sentinel runs in block mode in production, log mode in dev. Catches injection patterns at the edge before they reach handlers.
  • safefetch package — use safefetch.Client for any URL the user supplies (webhooks, OG-image preview, OEmbed expansion). Blocks private IP ranges + cloud metadata hostnames and re-validates the resolved IP at TCP-connect time to defeat DNS rebinding.

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)

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

TOTP login flow
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

MethodEndpointAuthPurpose
POST/api/auth/totp/setupJWTGenerate secret + QR code URI
POST/api/auth/totp/enableJWTVerify initial code, activate 2FA, get backup codes
POST/api/auth/totp/verifyPublicExchange pending token + TOTP code for JWT
POST/api/auth/totp/backup-codes/verifyPublicUse backup code during login
POST/api/auth/totp/disableJWTTurn off 2FA (requires password)
GET/api/auth/totp/statusJWTCheck 2FA status, backup codes remaining
POST/api/auth/totp/backup-codesJWTRegenerate backup codes
DELETE/api/auth/totp/trusted-devicesJWTRevoke all trusted devices

Enabling 2FA (User Flow)

Enable TOTP
// Step 1: Get the secret and QR code URI
const { 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 app
const { 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)

Login with TOTP
// Step 1: Normal login
const { data } = await api.post('/api/auth/login', { email, password })
if (data.totp_required) {
// Step 2: Redirect to TOTP verification page
// Store the pending token temporarily
const pendingToken = data.pending_token
// Step 3: User enters 6-digit code from authenticator app
const { 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.

Using a backup code
// 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 codes
trust_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:

Revoke 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:

.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
# 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.