Courses/Security Deep Dive
Standalone Course~30 min15 challenges

Security Deep Dive: Auth, 2FA, WAF & Rate Limiting

Most applications are vulnerable because security is added as an afterthought — a middleware bolted on after launch, a rate limiter added after an attack. Grit builds security in from day one. In this course, you will explore every security layer in a Grit application: password hashing, JWT tokens, role-based access, two-factor authentication, backup codes, trusted devices, a web application firewall, rate limiting, security headers, and OAuth2.


Why Security Matters

Before we look at Grit's security features, let's understand what we're defending against. Here are the most common attacks against web applications:

  • Brute Force: An attacker tries thousands of password combinations until one works. Without rate limiting, they can try millions of passwords per hour
  • SQL Injection: An attacker sends malicious SQL in form fields or URL parameters, tricking the database into executing unintended commands. Can dump your entire database
  • XSS (Cross-Site Scripting): An attacker injects JavaScript into your pages that runs in other users' browsers, stealing cookies, tokens, or personal data
  • CSRF (Cross-Site Request Forgery): An attacker tricks a logged-in user's browser into making requests to your API — transferring money, changing passwords, deleting data
  • Credential Stuffing: Attackers use leaked email/password combinations from other breaches to try logging into your app. Most people reuse passwords across sites
Security is not optional. A single breach can destroy user trust, violate regulations (GDPR, HIPAA), and cost millions in damages. The good news: Grit's 10-layer security stack defends against all of these attacks out of the box.
1

Challenge: Know Your Enemy

Name 3 common web attacks and explain how each one works in your own words. For each attack, describe: (1) what the attacker does, (2) what they gain if successful, and (3) how you think it can be prevented.

Layer 1: Password Security (bcrypt)

The most fundamental security rule: never store passwords in plain text. If an attacker gains access to your database (through SQL injection, a backup leak, or an insider threat), plain text passwords give them immediate access to every account. Instead, Grit stores passwords as one-way hashes.

Hash: A one-way mathematical function that converts input (like a password) into a fixed-length string of characters. The key property: you can hash a password to get the hash, but you cannot reverse the hash to get the password. To verify a login, you hash the submitted password and compare hashes.
Salt: A random string added to the password before hashing. Without a salt, two users with the same password would have the same hash — making it easy to crack with precomputed tables (rainbow tables). With a salt, every hash is unique even for identical passwords.
bcrypt: A password hashing algorithm specifically designed to be slow. It includes a built-in salt and a configurable "cost factor" that controls how many iterations of hashing are performed. Higher cost = slower hashing = harder to brute force. The default cost factor is 10, meaning 2^10 (1,024) iterations.

Here's the process when a user registers:

  1. 1.User submits password: myPassword123
  2. 2.bcrypt generates a random salt: $2a$10$N9qo8uLOickgx2ZMRZoMye
  3. 3.bcrypt hashes password + salt through 1,024 iterations
  4. 4.Result stored in database: $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
apps/api/internal/service/auth_service.go
// Hashing a password during registration
hashedPassword, err := bcrypt.GenerateFromPassword(
    []byte(input.Password),
    bcrypt.DefaultCost, // Cost factor 10 = 2^10 iterations
)

// Verifying a password during login
err := bcrypt.CompareHashAndPassword(
    []byte(user.Password), // stored hash
    []byte(input.Password), // submitted password
)
2

Challenge: Find the Hash

Open the auth service code in your project. Find where bcrypt hashes the password during registration and where it compares during login. What cost factor is used? What would happen if you changed it to 14? (Hint: each increment doubles the time.)

Layer 2: JWT Authentication

After verifying a password, Grit issues two tokens: an access token (short-lived, 15 minutes) and a refresh token (long-lived, 7 days). This dual-token approach limits the damage if a token is stolen.

Why two tokens? If an access token is intercepted by an attacker, it only works for 15 minutes. The refresh token is stored in an HTTP-only cookie (inaccessible to JavaScript), so XSS attacks cannot steal it. When the access token expires, the frontend silently uses the refresh token to get a new one.

Token Lifecycle
Login successful
  ├── Access Token (JWT, 15 min expiry)
  │   └── Sent in Authorization header: Bearer <token>
  │   └── Contains: user ID, email, role
  │   └── Stateless — no database lookup needed
  │
  └── Refresh Token (JWT, 7 day expiry)
      └── Stored in HTTP-only cookie
      └── Used only to get new access tokens
      └── Cannot be read by JavaScript (XSS protection)

The auth middleware runs on every protected route. It extracts the JWT from the Authorization header, verifies the signature, checks the expiration, and attaches the user information to the request context.

3

Challenge: Decode a JWT

Login to your Grit API and copy the access token. Go to jwt.io and paste it. What information is in the payload? What algorithm is used for signing? What is the expiration time (exp claim)?

Layer 3: Role-Based Access Control

Authentication answers "who are you?" Authorization answers "what are you allowed to do?" Grit uses role-based access control (RBAC) with three built-in roles:

RolePermissions
ADMINFull access — manage users, view all data, access admin panel, system settings
EDITORCreate and edit content — manage resources, upload files, but cannot manage users
USERBasic access — view content, manage own profile, limited write access

The RequireRole middleware enforces access control at the route level:

apps/api/internal/routes/routes.go
// Public routes — no auth required
auth := api.Group("/auth")
auth.POST("/login", h.Login)
auth.POST("/register", h.Register)

// Protected routes — any authenticated user
users := api.Group("/users")
users.Use(middleware.AuthMiddleware(cfg))
users.GET("/me", h.GetMe)

// Admin-only routes
admin := api.Group("/admin")
admin.Use(middleware.AuthMiddleware(cfg))
admin.Use(middleware.RequireRole("ADMIN"))
admin.GET("/users", h.ListUsers)
admin.DELETE("/users/:id", h.DeleteUser)
4

Challenge: Test Role Enforcement

Log in as a user with the USER role. Try to access an ADMIN-only endpoint like GET /api/admin/users. What HTTP status code do you get? What does the error message say? Now log in as an ADMIN and try the same endpoint.

Layer 4: Two-Factor Authentication (TOTP)

Passwords can be stolen through phishing, keyloggers, or data breaches. Two-factor authentication (2FA) adds a second layer: something you know (password) plus something you have (your phone). Even if an attacker has your password, they cannot log in without your phone.

TOTP (Time-based One-Time Password): An algorithm defined in RFC 6238 that generates a 6-digit code that changes every 30 seconds. Both your authenticator app and the server share a secret key. They independently calculate the same code based on the current time. If the codes match, authentication succeeds.
Authenticator App: A mobile application (Google Authenticator, Authy, 1Password, Bitwarden) that stores TOTP secrets and displays the current 6-digit code. You scan a QR code to add an account, and the app generates codes offline — no internet connection needed.

The 2FA setup flow in Grit:

  1. 1.Generate secret. The API creates a random TOTP secret and returns it as a QR code URL.
  2. 2.Scan QR code. The user scans the QR code with their authenticator app, which stores the secret.
  3. 3.Verify code. The user enters the current 6-digit code from their app to prove it's working.
  4. 4.2FA enabled. The secret is saved to the database. Future logins require both password and TOTP code.
2FA Login Flow
Step 1: POST /api/auth/login
  Body: { "email": "user@example.com", "password": "..." }
  Response: { "requires_2fa": true, "temp_token": "..." }

Step 2: POST /api/auth/verify-2fa
  Body: { "temp_token": "...", "code": "482917" }
  Response: { "access_token": "...", "refresh_token": "..." }
The 30-second time window actually has a small grace period. Most TOTP implementations accept the previous code and the next code in addition to the current one. This accounts for slight clock drift between the server and the user's phone.
5

Challenge: Enable 2FA

Enable two-factor authentication on your account using Google Authenticator, Authy, or any TOTP-compatible app. Follow the setup flow: generate the secret, scan the QR code, enter the verification code. Then log out and log back in — you should be prompted for a 6-digit code.

Layer 5: Backup Codes

What happens if you lose your phone? Without backup codes, you're locked out of your account forever. Grit generates 10 one-time backup codes when 2FA is enabled. Each code can be used exactly once as a substitute for the TOTP code.

Backup codes are:

  • 10 codes generated when 2FA is enabled
  • One-time use — each code is invalidated after use
  • bcrypt-hashed in the database — if the database is compromised, the raw codes are not exposed
  • Shown once — the user sees the plain text codes only at generation time and must save them
Backup Code Format
Your backup codes (save these somewhere safe):

1.  a8f2-k9m3-p4x7
2.  b3n6-j7w2-q8r1
3.  c5t9-h1y4-s6v3
4.  d2p8-g3z7-u9e5
5.  e7k1-f6x4-w2m8
6.  f4j3-e9n6-y7t2
7.  g1w7-d2p5-z3k9
8.  h8y4-c7m1-a6j3
9.  i6z2-b4t8-e1w5
10. j3x9-a1s7-f4n6

Each code can only be used once.
Store them in a password manager or print them.
6

Challenge: Test Backup Codes

With 2FA enabled, use a backup code to log in instead of your authenticator app. Then try the same backup code again. What happens? How many backup codes do you have remaining?

Layer 6: Trusted Devices

Entering a 2FA code every time you log in on the same computer gets annoying fast. Trusted devices solve this: after you verify 2FA once, the server sets a 30-day cookie on your browser. On subsequent logins from that device, the 2FA step is skipped.

The trusted device cookie is:

  • Secure — only sent over HTTPS (in production)
  • HTTP-only — JavaScript cannot read it (XSS protection)
  • 30-day expiry — after 30 days, you'll need to enter a 2FA code again
  • Per-device — trusting your laptop does not trust your phone's browser
7

Challenge: Inspect the Cookie

After completing a 2FA verification, open your browser's DevTools (F12) and go to the Application tab → Cookies. Find the trusted device cookie. What is its name? What is its expiry date? Is it marked as HTTP-only and Secure?

Layer 7: Sentinel WAF

WAF (Web Application Firewall): A security layer that sits between the internet and your application, inspecting every incoming request. It blocks malicious requests — SQL injection attempts, XSS payloads, suspicious user agents, and known attack patterns — before they reach your handlers. Think of it as a bouncer at the door of your API.

Grit includes Sentinel, a built-in WAF that runs as middleware. It inspects every request and blocks:

  • SQL injection patterns — detects ' OR 1=1 --, UNION SELECT, and other injection attempts in query parameters and request bodies
  • XSS attempts — blocks <script> tags, event handlers like onerror=, and other JavaScript injection vectors
  • Suspicious user agents — blocks known attack tools like sqlmap, nikto, and dirbuster
  • Path traversal — blocks attempts to access files outside the web root using ../../

Sentinel also includes a dashboard at /sentinel/ui where you can see:

  • Total requests processed
  • Blocked requests and the reasons they were blocked
  • Top attacking IPs
  • Attack type distribution
8

Challenge: Explore the WAF Dashboard

Visit /sentinel/ui in your browser. What metrics does the dashboard show? How many requests have been processed? Have any been blocked? Try sending a request with a SQL injection pattern in a query parameter and check if Sentinel catches it.

Layer 8: Rate Limiting

Rate Limiting: Restricting how many requests a client can make within a time window. For example, 100 requests per minute per IP address. This prevents brute force attacks (trying thousands of passwords), DDoS attacks (overwhelming your server with traffic), and API abuse (scraping all your data).

Grit's Sentinel middleware includes configurable rate limiting. Limits are applied per IP address and can be configured per route or globally:

Rate Limiting Configuration
// Default rate limits in Grit
Global:     100 requests per minute per IP
Auth:       10 requests per minute per IP (login, register)
Upload:     20 requests per minute per IP
API:        60 requests per minute per IP

// When a client exceeds the limit:
// HTTP 429 Too Many Requests
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Too many requests. Please try again later.",
    "details": {
      "retry_after": 45
    }
  }
}

The auth endpoints have a stricter limit (10/minute) because brute force attacks target login endpoints. An attacker trying 10,000 passwords per minute would be blocked after the first 10 attempts.

Rate limiting is enforced using Redis for distributed counting. If your API runs on multiple servers behind a load balancer, the rate limit is shared across all instances. An attacker cannot bypass the limit by hitting different servers.
9

Challenge: Find the Rate Limits

What rate limits are configured in your Grit project by default? Look at the Sentinel configuration in your code. What happens when you exceed the limit? What HTTP status code is returned? What does the retry_after field tell the client?

Layer 9: Security Headers

HTTP response headers can instruct browsers to enable security features. Grit sets these headers on every response:

HeaderValuePrevents
Strict-Transport-Securitymax-age=31536000Forces HTTPS for 1 year, prevents downgrade attacks
X-Frame-OptionsDENYPrevents your site from being embedded in iframes (clickjacking)
X-Content-Type-OptionsnosniffPrevents browsers from guessing content types (MIME sniffing)
Content-Security-Policydefault-src 'self'Controls which resources can load, blocks inline scripts (XSS)
Referrer-Policystrict-origin-when-cross-originLimits referrer information sent to external sites (privacy)
10

Challenge: Check Your Headers

Open DevTools (F12) in your browser. Go to the Network tab. Make any request to your API. Click on the request and look at the Response Headers section. List all the security headers you see. Are all five headers from the table present?

Layer 10: JWT Secret Management

The JWT secret is the key used to sign and verify tokens. If an attacker knows your secret, they can forge tokens for any user — including admin accounts. The secret must be:

  • Long — at least 32 characters (256 bits)
  • Random — generated by a cryptographic random number generator, not a human-chosen phrase
  • Secret — stored in environment variables, never committed to Git
  • Unique per environment — development, staging, and production should each have different secrets
Generate a Strong JWT Secret
# Generate a 64-character hex string (256 bits of entropy)
openssl rand -hex 32

# Example output:
# a3f8b2c1e9d7f4a6b8c2e1d3f5a7b9c1e3d5f7a9b1c3e5d7f9a1b3c5e7d9f1a3

# Add to your .env file (NEVER commit this file)
JWT_SECRET=a3f8b2c1e9d7f4a6b8c2e1d3f5a7b9c1e3d5f7a9b1c3e5d7f9a1b3c5e7d9f1a3
Changing the JWT secret invalidates ALL existing tokens instantly. Every logged-in user will be forced to log in again. This is actually useful — if you suspect a token has been compromised, rotating the secret is the nuclear option that immediately revokes all sessions.
11

Challenge: Secure Your Secret

Generate a strong JWT secret using openssl rand -hex 32. Replace the default JWT_SECRET in your .env file. Restart the API. Are you still logged in? (You should not be — the old tokens are now invalid.)

OAuth2 Security

Social login (Google, GitHub, etc.) is not just convenient — it's a security feature. When users log in with Google, your application never sees their password. This prevents:

  • Password reuse attacks — if a user's password is leaked from another site, your app is unaffected because you never stored a password
  • Phishing — users authenticate on Google's domain, not on a page you control, so fake login pages are harder to create
  • Weak passwords — Google enforces its own password policies and 2FA, which are typically stronger than what most apps enforce

Grit links OAuth accounts by email. If a user registers with email/password and later logs in with Google using the same email, the accounts are automatically linked. One user, multiple login methods.

12

Challenge: Explore OAuth Config

Look at the OAuth configuration in your project. What scopes does Google OAuth request? (Scopes define what information your app can access from Google.) What happens if a user logs in with Google using an email that already has a password account?

Summary: The 10-Layer Security Stack

LayerFeatureProtects Against
1bcrypt PasswordsDatabase leaks, rainbow tables
2JWT TokensSession hijacking, token theft (short expiry)
3RBACPrivilege escalation, unauthorized access
4TOTP 2FAStolen passwords, phishing
5Backup CodesLost device lockout
6Trusted Devices2FA fatigue (usability without sacrificing security)
7Sentinel WAFSQL injection, XSS, path traversal
8Rate LimitingBrute force, DDoS, API abuse
9Security HeadersClickjacking, MIME sniffing, protocol downgrade
10JWT Secret ManagementToken forgery, session compromise
13

Challenge: Security Audit — Part 1

Perform the first half of a security audit on your Grit application:

  1. Check your JWT_SECRET — is it at least 32 characters? Is it random (not a dictionary word)?
  2. Verify password hashing — register a user, then look at the users table in GORM Studio. Is the password a bcrypt hash (starts with $2a$)?
  3. Test 2FA — enable it, log out, log back in. Does it require the 6-digit code?
  4. Test a backup code — use one, then try it again. Is it rejected the second time?
14

Challenge: Security Audit — Part 2

Complete the second half of the security audit:

  1. Test rate limiting — send 15 rapid login requests. Are you rate limited?
  2. Check security headers — inspect response headers in DevTools. Are all 5 headers present?
  3. Test RBAC — log in as USER, attempt an ADMIN endpoint. Is it blocked?
  4. Check the Sentinel dashboard — are any attacks logged?
15

Challenge: Final Challenge: Write a Security Report

Write a one-page security report for your Grit application. For each of the 10 security layers, document: (1) whether it's enabled, (2) how it's configured, and (3) any improvements you would make. This is the kind of document you would give to a security auditor or include in your project's documentation.