Authentication & Authorization
In this course, you will learn how Grit handles security from the ground up: JWT-based authentication, bcrypt password hashing, role-based access control, two-factor authentication with TOTP, OAuth2 social login, and how the frontend manages auth state. By the end, you will understand every layer of Grit's auth system.
What is Authentication?
Security in web applications comes down to two fundamental questions: Who are you? and What are you allowed to do? These are handled by two separate systems that work together.
Grit handles both: JWT tokens for authentication and roles for authorization. Every API request goes through this two-step check: first, is the token valid? Second, does this user's role allow this action?
Challenge: Authentication vs Authorization
In your own words, explain the difference between authentication and authorization. Give an example from a real app — for instance, on Instagram, anyone with an account can view posts (authenticated), but only the post owner can delete it (authorized). Come up with your own example from an app you use daily.
How JWT Authentication Works
Grit uses JSON Web Tokens (JWTs) for authentication. Here is the complete flow, step by step:
- 1. User sends email + password to
POST /api/auth/registerorPOST /api/auth/login - 2. Server verifies credentials against the database (password hashed with bcrypt)
- 3. Server creates two tokens: an access token (short-lived, 15 minutes) and a refresh token (long-lived, 7 days)
- 4. Frontend stores both tokens
- 5. Every API request includes the access token in the
Authorizationheader - 6. When the access token expires, the frontend uses the refresh token to get a new one
- 7. When the refresh token expires, the user must log in again
header.payload.signature. You can decode a JWT at jwt.io to see its contents./api/auth/refresh endpoint. This way, the user does not have to log in every 15 minutes.Here is what happens when you register a new user:
// Request
{
"name": "John Doe",
"email": "john@example.com",
"password": "securePassword123"
}
// Response
{
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"role": "USER"
}
}
}Notice the response includes both tokens and the user object. The password is never returned. Logging in works the same way:
{
"email": "john@example.com",
"password": "securePassword123"
}Once you have the access token, every subsequent API request must include it in the Authorization header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...Authorization: Bearer <token>.Challenge: Register a User
Open the API docs at localhost:8080/docs. Find the register endpoint. Register a new user with your name and email. Copy the access_token from the response — you will need it for the next challenge.
Challenge: Use the Access Token
Use the access_token you copied to call GET /api/users. In the API docs, paste it in the Authorization field. Does it return the user list? Now try calling the same endpoint without a token. What error code and message do you get?
Challenge: Decode a JWT
Copy your access token and paste it at jwt.io. Look at the payload section — can you see your user ID and role? What is the exp field? (Hint: it is a Unix timestamp for when the token expires.)
Where Auth Lives in the Code
Grit's auth system is spread across four key files. Understanding where each piece lives helps you customize the auth behavior:
- •
apps/api/internal/handlers/auth.go— Register, Login, RefreshToken, and GetMe handlers - •
apps/api/internal/services/auth_service.go— Password hashing, token generation, user lookup - •
apps/api/internal/middleware/auth.go— JWT verification middleware - •
apps/api/internal/routes/routes.go— Public vs protected route groups
The most important concept is the split between public and protected routes:
// Public routes — no authentication required
public := r.Group("/api/auth")
public.POST("/register", authHandler.Register)
public.POST("/login", authHandler.Login)
public.POST("/refresh", authHandler.RefreshToken)
// Protected routes — JWT required
protected := r.Group("/api")
protected.Use(middleware.Auth(cfg.JWTSecret))
protected.GET("/users", userHandler.List)
protected.GET("/me", authHandler.GetMe)Routes inside the "protected" group require a valid JWT. The Auth middleware runs before the handler and checks the token. If the token is missing, expired, or tampered with, the middleware returns 401 Unauthorized immediately and the handler never executes.
middleware.Auth() function extracts the user ID and role from the JWT and attaches them to the request context. Any handler in the protected group can then access c.GetUint("userID") and c.GetString("userRole") without re-parsing the token.Challenge: Explore the Auth Code
Open apps/api/internal/routes/routes.go. Find the public and protected groups. List 3 public routes and 3 protected routes you find in your project.
Challenge: Read the Middleware
Open apps/api/internal/middleware/auth.go. Find the line where the JWT token is extracted from the request header. What happens if the token is missing? What happens if the token is expired? Trace the code path for each case.
Roles and Role-Based Access Control
Authentication tells you who the user is. But that is only half the story. You also need to control what each user can do. Grit solves this with three built-in roles:
- ADMIN — Full access to everything: manage users, system settings, all resources
- EDITOR — Can create, edit, and publish content, but cannot manage users or system settings
- USER — Basic access: their own profile and public content
Here is how role-restricted routes look in Grit:
// Only ADMIN can access these
admin := protected.Group("/admin")
admin.Use(middleware.RequireRole("ADMIN"))
admin.DELETE("/users/:id", userHandler.Delete)
admin.PUT("/users/:id/role", userHandler.UpdateRole)The RequireRole middleware checks the user's role (which was set by the Auth middleware earlier). If the role does not match, the request is rejected with 403 Forbidden.
Here is the User model with the role field:
type User struct {
gorm.Model
Name string `gorm:"not null" json:"name"`
Email string `gorm:"uniqueIndex;not null" json:"email"`
Password string `gorm:"not null" json:"-"`
Role string `gorm:"default:USER" json:"role"`
}Two important details: json:"-" means the Password field is never included in API responses — it is invisible to the frontend. The Role field defaults to USER for all new registrations, so no one can register themselves as an ADMIN.
Challenge: Explore Your User Role
Open GORM Studio at localhost:8080/studio. Find your user in the users table. What role do they have? Change it to ADMIN directly in the database. Refresh the admin panel — do you see more options now?
Challenge: Test Role Restrictions
Create a second user through the web app (use a different email). This new user will have the default USER role. Try accessing the admin panel with this account. What happens? Can you access the user management page?
Adding Custom Roles
Three roles might not be enough for your app. A forum might need a MODERATOR. A marketplace might need a VENDOR. Grit makes adding custom roles easy:
grit add role MODERATORThis single command updates four places across your stack:
- 1. Go models — adds
MODERATORto the role constants - 2. TypeScript types — adds "MODERATOR" to the role union type
- 3. Zod schemas — adds "MODERATOR" to the role validation
- 4. Admin UI — adds MODERATOR as an option in role dropdowns
You can also restrict generated resources to specific roles:
grit generate resource Report --fields "title:string,content:text" --roles "ADMIN,MODERATOR"This generates routes that only ADMIN and MODERATOR can access. A regular USER trying to reach /api/reports will get 403 Forbidden.
Challenge: Add a Custom Role
Run grit add role MODERATOR. Then verify all four places were updated: (1) check the Go model constants, (2) check the TypeScript types file, (3) check the Zod schemas, and (4) open the admin user form — is MODERATOR an option in the role dropdown?
Challenge: Role-Restricted Resources
Generate a Report resource restricted to ADMIN and MODERATOR: grit generate resource Report --fields "title:string,content:text" --roles "ADMIN,MODERATOR". Log in as a regular USER and try to access /api/reports. What error do you get? Now change the user's role to MODERATOR and try again.
Two-Factor Authentication (TOTP)
Passwords alone are not enough. They can be guessed, phished, or leaked in data breaches. TOTP adds a second layer of security: even if someone steals your password, they cannot log in without the code from your authenticator app.
Here is how Grit's TOTP system works:
- 1. User calls
POST /api/totp/setup— server generates a secret key and returns a QR code URL - 2. User scans the QR code with their authenticator app (Google Authenticator, Authy, etc.)
- 3. User enters the 6-digit code to verify:
POST /api/totp/verify - 4. Server stores that 2FA is enabled for this user
- 5. On next login, after the password check, the server asks for the TOTP code
- 6. User enters the code from their app — if it matches, login succeeds
Here is the setup flow:
// Request (requires Authorization header)
POST /api/totp/setup
Authorization: Bearer <token>
// Response
{
"data": {
"secret": "JBSWY3DPEHPK3PXP",
"qr_url": "otpauth://totp/myapp:john@example.com?secret=JBSWY3DPEHPK3PXP&issuer=myapp",
"backup_codes": [
"a1b2c3d4",
"e5f6g7h8",
"i9j0k1l2",
"m3n4o5p6",
"q7r8s9t0",
"u1v2w3x4",
"y5z6a7b8",
"c9d0e1f2",
"g3h4i5j6",
"k7l8m9n0"
]
}
}The qr_url follows the otpauth:// URI format that all authenticator apps understand. The secret is the raw key — you can enter it manually if you cannot scan the QR code.
Challenge: Set Up TOTP
Call the TOTP setup endpoint from the API docs (you need to be logged in). Copy the qr_url from the response. If you have Google Authenticator or Authy on your phone, scan the QR code. Otherwise, use an online TOTP generator to test with the secret key.
Backup Codes
What if you lose your phone? Or your authenticator app gets deleted? You would be locked out of your account forever. That is where backup codes come in.
Grit generates 10 backup codes during TOTP setup. Each code is a one-time-use alternative to the TOTP code. They are hashed with bcrypt before being stored in the database — just like passwords, even if someone steals the database, they cannot read the backup codes.
To use a backup code, send it to the verify endpoint instead of a TOTP code:
{
"code": "a1b2c3d4"
}The server checks: is this a valid TOTP code? If not, is it a valid backup code? If it matches a backup code, the code is marked as used and can never be used again.
Challenge: Test Backup Codes
During TOTP setup, save your 10 backup codes. Use one of them to verify instead of a TOTP code — does it work? Now try using the same backup code again. It should fail because each code is one-time-use. How many backup codes do you have left?
Trusted Devices
Entering a TOTP code every single time you log in from your own computer gets annoying fast. Grit solves this with trusted devices.
When you verify a TOTP code, you can set a "trust this device" flag. The server creates a secure cookie that lasts 30 days. During those 30 days, that browser skips the TOTP step entirely — password only. After 30 days, or if you clear your cookies, you will need to enter a TOTP code again.
Challenge: Check Trusted Device Cookie
After enabling 2FA and verifying with a TOTP code (with "trust this device"enabled), open your browser DevTools (F12) and go to the Application tab (Chrome) or Storage tab (Firefox). Look at Cookies for your domain. Can you find the trusted device cookie? What is its expiry date? Is it marked as HttpOnly?
OAuth2 Social Login
Not everyone wants to create yet another password. OAuth2 lets users sign in with their existing Google or GitHub account instead.
Grit uses the goth library for OAuth2. You configure it by adding client credentials to your .env file:
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secretHere is the complete OAuth2 flow:
- 1. User clicks "Sign in with Google" on your login page
- 2. Browser redirects to Google's login page
- 3. User logs in with their Google account
- 4. Google redirects back to your app with a temporary code
- 5. Your API exchanges the code for the user's email and name
- 6. If the email already exists in your database, the accounts are linked
- 7. If it is a new email, a new account is created automatically
Challenge: Explore OAuth2 Config
Open the .env file in your project root. Find the GOOGLE_CLIENT_ID and GITHUB_CLIENT_ID variables. They are empty by default. Look at the login page of your web app — do you see the Google and GitHub sign-in buttons? (They will not work without client IDs configured, but the UI should already be there.)
The JWT_SECRET
The JWT_SECRET is arguably the most important configuration value in your app. It is the key used to sign and verify every JWT token. Anyone who has this secret can create valid tokens for any user — including admin tokens.
This is why:
- • It must be long and random (at least 32 characters)
- • It must never be committed to Git (it is in
.env, which is in.gitignore) - • It must be different in development and production
Here is how to generate a cryptographically strong secret:
openssl rand -hex 32
# Output: a3f8b2c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1JWT_SECRET, all existing tokens become invalid and every logged-in user will be forced to log in again. This is actually useful if you suspect tokens have been compromised — change the secret to immediately invalidate all sessions.Challenge: Secure Your JWT_SECRET
Open your .env file. Look at the JWT_SECRET value. Is it a strong random string, or is it a weak default like "secret" or "changeme"? Generate a proper secret with openssl rand -hex 32 and replace it. Restart the API server. What happens to your currently logged-in session? (You should be logged out.)
Auth in the Frontend
The backend handles token creation and verification, but the frontend is responsible for storing tokens, attaching them to requests, and managing auth state. Here is how Grit's web app handles it:
- • On login: stores the
access_tokenandrefresh_token - • On every API call: attaches the
Authorization: Bearer <token>header automatically - • When access token expires: automatically calls
/api/auth/refreshto get a new one - • On logout: clears both tokens and redirects to the login page
- • Auth context: a React context that provides user data and auth state to all components
Protected pages use the auth hook to redirect unauthenticated users:
// If not logged in, redirect to /login
const { user, isLoading } = useAuth()
if (!isLoading && !user) redirect("/login")This pattern runs on every protected page. While isLoading is true (the auth state is being checked), the page shows a loading spinner. Once loading completes, if there is no user, the visitor is immediately redirected to the login page. They never see the protected content.
401 Unauthorized, the API client automatically tries to refresh the token. If the refresh succeeds, it retries the original request. If the refresh fails (the refresh token is also expired), the user is logged out. This means users stay logged in for up to 7 days without any action.Challenge: Test Frontend Auth Flow
Do two things: (1) Log out of your web app. Try to visit /dashboard directly by typing the URL in the address bar. You should be redirected to the login page. (2) Open browser DevTools (F12) and go to the Network tab. Log in and watch the requests. Can you see the /api/auth/login request? Look at the response — do you see both tokens?
Summary
You now understand Grit's complete auth system, from password hashing to social login. Here is everything you learned:
- Authentication vs authorization — who you are vs what you can do
- JWT flow — register, login, access token (15 min), refresh token (7 days)
- bcrypt password hashing — one-way hashing, never store plain text
- Middleware-based route protection — Auth middleware checks tokens, RequireRole checks permissions
- 3 built-in roles — ADMIN, EDITOR, USER — plus custom roles with
grit add role - TOTP two-factor authentication — 6-digit codes from authenticator apps, QR code setup
- Backup codes — 10 one-time-use recovery codes, bcrypt-hashed in the database
- Trusted devices — 30-day secure cookies to skip TOTP on known browsers
- OAuth2 social login — Google and GitHub sign-in via the goth library
- JWT_SECRET security — random, never committed, change to invalidate all sessions
- Frontend auth flow — token storage, auto-refresh, protected routes, auth context
Challenge: Final Challenge: Complete Auth Setup
Put everything together in one session:
- Register a user through the API or web app
- Add a MODERATOR role with
grit add role MODERATOR - Enable 2FA on your admin account using the TOTP setup endpoint
- Test backup codes — use one, verify it works, try reusing it
- Change the JWT_SECRET in your
.envfile and restart the API — observe what happens to your session - Generate a role-restricted resource with
--roles "ADMIN,MODERATOR"and verify a USER cannot access it
Enjoying the course?
Help us grow — star us on GitHub, subscribe on YouTube, and follow on LinkedIn.