Courses/Grit Web/Authentication & Authorization
Course 3 of 8~30 min16 challenges

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.

Authentication: Verifying who a user is. When you log in with an email and password, the server checks your credentials against the database. If they match, you are authenticated — the server now knows who you are. Think of it like showing your ID at the door.
Authorization: Determining what an authenticated user is allowed to do. An ADMIN can delete users, but a regular USER cannot. A post owner can edit their own posts, but not someone else's. Authorization happens after authentication — you must know WHO someone is before you can decide WHAT they can do.

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?

1

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. 1. User sends email + password to POST /api/auth/register or POST /api/auth/login
  2. 2. Server verifies credentials against the database (password hashed with bcrypt)
  3. 3. Server creates two tokens: an access token (short-lived, 15 minutes) and a refresh token (long-lived, 7 days)
  4. 4. Frontend stores both tokens
  5. 5. Every API request includes the access token in the Authorization header
  6. 6. When the access token expires, the frontend uses the refresh token to get a new one
  7. 7. When the refresh token expires, the user must log in again
JWT (JSON Web Token): A compact, URL-safe token that contains encoded information about the user (like their ID and role). It is signed with a secret key so the server can verify it was not tampered with. The format is three parts separated by dots: header.payload.signature. You can decode a JWT at jwt.io to see its contents.
Access Token: A short-lived JWT (15 minutes in Grit) that is included in every API request. The short expiry limits the damage if the token is stolen — an attacker only has 15 minutes before it becomes useless.
Refresh Token: A long-lived token (7 days in Grit) used only to get a new access token. It is stored securely and sent only to the /api/auth/refresh endpoint. This way, the user does not have to log in every 15 minutes.
bcrypt: A password hashing algorithm. Grit never stores plain text passwords. When you register, bcrypt creates a one-way hash — a scrambled version that cannot be reversed back into the original password. When you login, bcrypt compares your input against the stored hash. Even if the database is stolen, attackers cannot read the passwords.

Here is what happens when you register a new user:

POST /api/auth/register
// 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:

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

Once you have the access token, every subsequent API request must include it in the Authorization header:

GET /api/users
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Bearer Token: The format for sending JWTs in HTTP headers. "Bearer" means "the person bearing (carrying) this token is authorized." The full header format is Authorization: Bearer <token>.
2

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.

3

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?

4

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:

apps/api/internal/routes/routes.go
// 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.

Route Group: A collection of routes that share a common prefix and/or middleware. In Grit, "public" routes (like login and register) have no auth requirement, while "protected" routes require a valid JWT token. This keeps the auth logic in one place instead of checking it in every handler.
The 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.
5

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.

6

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
RBAC (Role-Based Access Control): A security model where permissions are assigned to roles, and roles are assigned to users. Instead of checking "can user #42 delete posts?", you check "does this user have the ADMIN role?" This is much simpler to manage — when a new admin joins, you assign the ADMIN role instead of configuring individual permissions.

Here is how role-restricted routes look in Grit:

apps/api/internal/routes/routes.go
// 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:

apps/api/internal/models/user.go
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.

7

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?

8

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:

Terminal
grit add role MODERATOR

This single command updates four places across your stack:

  1. 1. Go models — adds MODERATOR to the role constants
  2. 2. TypeScript types — adds "MODERATOR" to the role union type
  3. 3. Zod schemas — adds "MODERATOR" to the role validation
  4. 4. Admin UI — adds MODERATOR as an option in role dropdowns

You can also restrict generated resources to specific roles:

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

9

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?

10

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.

Two-Factor Authentication (2FA): Requiring two different things to log in: (1) something you know (your password) and (2) something you have (your authenticator app on your phone). Even if your password is compromised, the attacker needs your phone too.
TOTP (Time-Based One-Time Password): A 6-digit code that changes every 30 seconds, generated by an authenticator app like Google Authenticator, Authy, or 1Password. It is based on RFC 6238 — a shared secret combined with the current time produces a unique code. Both the server and your app generate the same code independently, so no network request is needed.

Here is how Grit's TOTP system works:

  1. 1. User calls POST /api/totp/setup — server generates a secret key and returns a QR code URL
  2. 2. User scans the QR code with their authenticator app (Google Authenticator, Authy, etc.)
  3. 3. User enters the 6-digit code to verify: POST /api/totp/verify
  4. 4. Server stores that 2FA is enabled for this user
  5. 5. On next login, after the password check, the server asks for the TOTP code
  6. 6. User enters the code from their app — if it matches, login succeeds

Here is the setup flow:

POST /api/totp/setup
// 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.

11

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:

POST /api/totp/verify
{
  "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.

Store your backup codes somewhere safe — a password manager, a printed piece of paper in a secure location, or an encrypted note. If you lose both your phone AND your backup codes, the only way back into your account is through a database admin manually disabling 2FA.
12

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.

Cookie: A small piece of data stored in the browser by the server. The browser automatically sends it with every request to that domain. Grit uses a secure, HTTP-only cookie for trusted devices — "secure" means it is only sent over HTTPS, and "HTTP-only" means JavaScript cannot read it (protecting against XSS attacks).
13

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.

OAuth2: An authorization protocol that lets users grant your app limited access to their account on another service (Google, GitHub) without sharing their password. The user clicks "Sign in with Google", Google confirms their identity, and sends your app a token with the user's basic info (name and email). Read more in the OAuth 2.0 specification.

Grit uses the goth library for OAuth2. You configure it by adding client credentials to your .env file:

.env
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-secret

Here is the complete OAuth2 flow:

  1. 1. User clicks "Sign in with Google" on your login page
  2. 2. Browser redirects to Google's login page
  3. 3. User logs in with their Google account
  4. 4. Google redirects back to your app with a temporary code
  5. 5. Your API exchanges the code for the user's email and name
  6. 6. If the email already exists in your database, the accounts are linked
  7. 7. If it is a new email, a new account is created automatically
To set up Google OAuth2, go to the Google Cloud Console and create OAuth2 credentials. For GitHub, go to GitHub Developer Settings and create a new OAuth App. Both services are free for development.
14

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:

Terminal
openssl rand -hex 32
# Output: a3f8b2c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1
If you change the JWT_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.
15

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_token and refresh_token
  • On every API call: attaches the Authorization: Bearer <token> header automatically
  • When access token expires: automatically calls /api/auth/refresh to 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:

Protected Page Pattern
// 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.

The token refresh happens transparently. When an API call returns 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.
16

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
17

Challenge: Final Challenge: Complete Auth Setup

Put everything together in one session:

  1. Register a user through the API or web app
  2. Add a MODERATOR role with grit add role MODERATOR
  3. Enable 2FA on your admin account using the TOTP setup endpoint
  4. Test backup codes — use one, verify it works, try reusing it
  5. Change the JWT_SECRET in your .env file and restart the API — observe what happens to your session
  6. Generate a role-restricted resource with --roles "ADMIN,MODERATOR" and verify a USER cannot access it