Authentication & Authorization 06
This page covers the complete authentication and authorization system: JWT token generation and validation, token revocation via Redis versioning, refresh token rotation with reuse detection, hybrid authentication middleware, RBAC (role-based access control), and AES-256 encryption for secrets at rest. Every authenticated request flows through these mechanisms.
6.1 JWT Token Generation #
auth-service/internal/repository/auth_repo.go —
GenerateAccessToken
When a user logs in, the auth-service generates a signed JWT containing identity, tenant context, role, permissions, and version counters. This token is the sole credential for all subsequent API requests.
// Build claims with identity, tenant, and authorization data
claims := jwt.MapClaims{
"sub": user.ID,
"tenant_id": user.TenantId,
"role": roleName,
"subdomain": tenantData["Subdomain"],
"permissions": permissions, // map[string][]string
"role_id": user.RoleId,
"token_version": tokenVersion,
"role_version": roleVersion,
"role_user_version": roleUserVersion,
"tenant_feature": tenantFeature, // []string
"exp": time.Now().Add(accessTokenExpiry).Unix(),
}
// Sign with HMAC-SHA256
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, err := token.SignedString([]byte(r.secret))
Claims Reference
| Claim | Type | Purpose |
|---|---|---|
sub |
string (UUID) |
User ID — uniquely identifies the authenticated user |
tenant_id |
string (UUID) |
Tenant ID — scopes all data access to a single tenant |
role |
string |
Role name (e.g. "admin", "user") for coarse-grained checks |
subdomain |
string |
Tenant subdomain — used for routing and display |
permissions |
map[string][]string |
Fine-grained RBAC: menu slug → allowed actions (e.g. {"users": ["read","update"]}) |
role_id |
string (UUID) |
Role ID — used for version checking against Redis |
token_version |
int |
Incremented on token revocation — enables stateless token invalidation |
role_version |
int |
Incremented when role permissions change — forces re-login for updated permissions |
role_user_version |
int |
Incremented when user’s role assignment changes — forces re-auth on role swap |
tenant_feature |
[]string |
Feature flags enabled for the tenant (e.g. ["broadcast","chatbot"]) |
exp |
int64 (Unix) |
Expiration timestamp — defaults to 15 hours from creation |
Token Configuration
| Parameter | Value | Source |
|---|---|---|
| Signing algorithm | HS256 (HMAC-SHA256) | Hardcoded in jwt.SigningMethodHS256 |
| Access token expiry | 15 hours (default) | Configurable via ACCESS_TOKEN_EXPIRY env var |
| Signing key | Shared secret | JWT_SIGNING_KEY env var |
Why HS256? HMAC-SHA256 uses a symmetric shared secret. Since both the auth-service (issuer) and the gateway/services (verifiers) are internal and trusted, asymmetric signing (RS256) is unnecessary overhead. The shared secret is distributed via environment variables.
6.2 JWT Validation (JWTMiddleware) #
pkg/auth/jwt.go —
JWTMiddleware
Every authenticated request passes through JWTMiddleware. It validates the token
signature, extracts claims, then verifies version counters against Redis to
ensure the token has not been revoked. Only after both checks pass are the claims injected
into the request context.
func JWTMiddleware(cfg JWTConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Step 1: Validate JWT signature and extract claims
claims, err := ValidateJWT(r, cfg)
if err != nil {
httpx.SendError(w, r, err)
return
}
// Step 2: Extract version counters from claims
userId, _ := helpers.AnyToString(claims["sub"])
roleId, _ := helpers.AnyToString(claims["role_id"])
tokenVersion, _ := helpers.AnyToInt(claims["token_version"])
roleVersion, _ := helpers.AnyToInt(claims["role_version"])
roleUserVersion, _ := helpers.AnyToInt(claims["role_user_version"])
// Step 3: Verify versions against Redis (revocation check)
verifyErr := redisclient.VerifyAuthVersions(
ctx, userId, tokenVersion,
roleId, roleVersion, roleUserVersion,
)
if verifyErr != nil {
httpx.SendError(w, r, verifyErr)
return
}
// Step 4: Inject claims into request context
ctx := WithClaims(r.Context(), claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
The middleware performs a two-phase validation: first cryptographic (JWT signature + expiry), then stateful (Redis version check). This gives the speed of stateless JWTs with the revocation capability of server-side sessions.
6.3 Token Revocation via Redis Versioning #
Three version counters are stored in Redis and checked on every authenticated request:
| Redis Key | Incremented When | Effect |
|---|---|---|
auth:token_version:{user_id} |
User’s token is explicitly revoked (logout, password change) | Invalidates all existing access tokens for that user |
auth:role_version:{role_id} |
Role permissions are modified (admin updates RBAC) | Forces all users with that role to re-authenticate |
auth:role_user_version:{user_id} |
User’s role assignment changes (user moved to different role) | Forces that specific user to re-authenticate |
The comparison is simple: if the version in the JWT claim does not match the current version in Redis, the request is rejected with 401 Unauthorized.
Why version counters? Stateless JWTs cannot be revoked once issued — they remain valid until expiry. Redis version counters provide near-instant revocation without requiring a full session store. Incrementing a single integer in Redis immediately invalidates all tokens carrying the old version, even though those tokens are cryptographically valid.
Version Check Flow
Client Request (with JWT)
│
▼
┌─────────────────────┐
│ JWTMiddleware │
│ Validate signature │
│ + check expiry │
└────────┬────────────┘
│ valid
▼
┌─────────────────────────────────────────────────────────┐
│ Extract from JWT claims: │
│ token_version = 5 │
│ role_version = 3 │
│ role_user_version = 2 │
└────────┬────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Redis Lookup: │
│ GET auth:token_version:{user_id} → 5 ✓ match │
│ GET auth:role_version:{role_id} → 3 ✓ match │
│ GET auth:role_user_version:{user_id} → 2 ✓ match │
└────────┬────────────────────────────────────────────────┘
│ all match
▼
┌─────────────────────┐
│ Request proceeds │
│ (claims in context) │
└─────────────────────┘
── OR ──
┌─────────────────────────────────────────────────────────┐
│ Redis Lookup: │
│ GET auth:token_version:{user_id} → 6 ✗ MISMATCH│
└────────┬────────────────────────────────────────────────┘
│
▼
┌─────────────────────┐
│ 401 Unauthorized │
│ "token revoked" │
└─────────────────────┘
6.4 Refresh Token Flow #
auth-service/internal/repository/auth_repo.go
Refresh tokens are long-lived credentials used to obtain new access tokens without re-entering credentials. They are UUIDs stored as SHA-256 hashes in PostgreSQL, delivered via HttpOnly, Secure, SameSite=Lax cookies.
Refresh Flow
- Client sends the refresh token cookie to
POST /api/v1/auth/refresh - Server hashes the token (SHA-256) and looks up the hash in the
refresh_tokenstable - Checks: not revoked (
revoked_at IS NULL), not expired - Generates a new access token + new refresh token
- Revokes the old refresh token (sets
revoked_at) - Returns the new access token in JSON body, new refresh token as cookie
Client Auth Service PostgreSQL
│ │ │
│ POST /auth/refresh │ │
│ Cookie: refresh_token=UUID │ │
│────────────────────────────▶│ │
│ │ SHA-256(UUID) │
│ │ SELECT * WHERE hash = ? │
│ │─────────────────────────────▶│
│ │ refresh_token row │
│ │◀─────────────────────────────│
│ │ │
│ │ Check: revoked_at IS NULL? │
│ │ Check: expires_at > now? │
│ │ │
│ │ Generate new access JWT │
│ │ Generate new refresh UUID │
│ │ │
│ │ UPDATE SET revoked_at=now │
│ │ INSERT new refresh token │
│ │─────────────────────────────▶│
│ │ │
│ Set-Cookie: refresh_token │ │
│ JSON: { token: "eyJ..." } │ │
│◀────────────────────────────│ │
Reuse Detection (Compromise Mitigation)
If a revoked refresh token is presented again, it indicates that either the token was stolen or there is a replay attack. The system responds by revoking ALL of that user’s active refresh tokens:
if rt.RevokedAt != nil {
// Compromise detected! Revoke ALL tokens for this user
tx.Model(&RefreshToken{}).
Where("user_id = ? AND revoked_at IS NULL", rt.UserID).
Update("revoked_at", now)
return httpx.Unauthorized("refresh token reuse detected")
}
Why revoke all tokens on reuse? If an attacker stole a refresh token and used it, the legitimate user’s next refresh attempt will present the now-revoked token. This “trip wire” detects the compromise and forces the attacker out by invalidating every token in the family. The legitimate user must log in again, but the attacker can no longer escalate.
Cookie Configuration
| Attribute | Value | Purpose |
|---|---|---|
HttpOnly |
true |
Prevents JavaScript access — mitigates XSS token theft |
Secure |
true |
Only sent over HTTPS |
SameSite |
Lax |
Prevents CSRF while allowing top-level navigations |
6.5 Hybrid Authentication (HybridAuthMiddleware) #
pkg/auth/hybrid.go
HybridAuthMiddleware allows services to operate in two modes:
behind the gateway (receiving pre-validated headers) or
standalone (validating JWTs directly). This is essential for
internal service-to-service calls and local development.
Two Authentication Paths
| Path | Trigger | How It Works |
|---|---|---|
| Path 1: JWT | Authorization: Bearer <token> header present |
Service validates JWT directly, extracts TenantContext from claims |
| Path 2: Headers | No Authorization header; gateway-injected headers present |
Reads X-Tenant-ID, X-User-ID, X-User-Role, X-Subdomain headers |
if authHeader != "" {
// Path 1: Client sent JWT directly → validate it
tenantCtx, err = authenticateWithJWT(w, r, authHeader, signingKey)
} else {
// Path 2: Gateway already validated → trust injected headers
tenantCtx, err = authenticateWithHeaders(w, r)
}
┌──────────────────────────────────────────────────────────┐
│ Client Request │
└────────────────────────┬─────────────────────────────────┘
│
▼
┌───────────────┐
│ Has Bearer │
│ token header? │
└───┬───────┬───┘
yes │ │ no
▼ ▼
┌───────────────┐ ┌────────────────────────┐
│ Path 1: JWT │ │ Path 2: Headers │
│ │ │ │
│ ValidateJWT() │ │ Read X-Tenant-ID │
│ Extract claims│ │ Read X-User-ID │
│ Build context │ │ Read X-User-Role │
└───────┬───────┘ │ Read X-Subdomain │
│ └───────────┬────────────┘
│ │
▼ ▼
┌──────────────────────────────────────┐
│ TenantContext in context │
│ → next.ServeHTTP(w, r) │
└──────────────────────────────────────┘
Why hybrid? In production, services sit behind the gateway which handles JWT validation and injects headers. But during development, testing, and internal service-to-service calls, services may receive requests directly. The hybrid approach lets the same service code work in both scenarios without configuration changes.
6.6 Gateway Authentication #
gateway-service/internal/handler/router.go
The gateway is the single entry point for external requests. It validates JWTs once, then injects tenant context as HTTP headers for downstream services. This avoids redundant JWT validation across multiple services in a request chain.
func (rtr *Router) validateAndInjectTenantHeaders(
w http.ResponseWriter, r *http.Request,
) (TenantContext, error) {
// Extract Bearer token from Authorization header
tokenString := extractBearerToken(r)
// Validate with KeyManager (handles key rotation)
tenantCtx, err := auth.ValidateAndExtractToken(tokenString, rtr.km)
if err != nil {
return TenantContext{}, err
}
// Inject headers for downstream services
r.Header.Set("X-Tenant-ID", tenantCtx.TenantID)
r.Header.Set("X-User-ID", tenantCtx.UserID)
r.Header.Set("X-User-Role", tenantCtx.Role)
r.Header.Set("X-Subdomain", tenantCtx.Subdomain)
return tenantCtx, nil
}
Injected Headers
| Header | Source (JWT claim) | Used By |
|---|---|---|
X-Tenant-ID |
tenant_id |
All services — scopes database queries to tenant |
X-User-ID |
sub |
All services — identifies the acting user |
X-User-Role |
role |
Authorization middleware — role-based checks |
X-Subdomain |
subdomain |
Routing and multi-tenant context resolution |
External Client
│
│ Authorization: Bearer eyJ...
▼
┌─────────────────────────────────────┐
│ Gateway Service │
│ │
│ 1. Extract Bearer token │
│ 2. Validate JWT signature │
│ 3. Extract claims → TenantContext │
│ 4. Inject X-Tenant-ID header │
│ 5. Inject X-User-ID header │
│ 6. Inject X-User-Role header │
│ 7. Inject X-Subdomain header │
│ 8. Proxy to downstream service │
└──────────────┬──────────────────────┘
│
│ X-Tenant-ID: abc-123
│ X-User-ID: user-456
│ X-User-Role: admin
│ X-Subdomain: acme
▼
┌─────────────────────────────────────┐
│ Downstream Service │
│ (HybridAuth → Path 2: Headers) │
└─────────────────────────────────────┘
6.7 RBAC — Role-Based Access Control #
The RBAC system has three layers: role-level checks (is the user an admin?), permission-level checks (can they create broadcasts?), and tenant ownership checks (do they belong to this tenant?). All permission data is embedded in the JWT, so checks are local and fast.
Permission Model
┌─────────────┐ ┌──────────────┐ ┌────────────────────────────┐
│ Menu │ │ RoleMenu │ │ Role │
│ │ │ │ │ │
│ Name │◀──────│ MenuID │──────▶│ ID │
│ Slug │ │ RoleID │ │ Name │
│ │ │ CanCreate │ │ │
│ │ │ CanRead │ │ │
│ │ │ CanUpdate │ │ │
│ │ │ CanDelete │ │ │
└─────────────┘ └──────────────┘ └────────────────────────────┘
│
▼ Embedded in JWT as:
┌──────────────────────────────┐
│ "permissions": { │
│ "broadcast": ["read", │
│ "create"], │
│ "users": ["read", │
│ "update"] │
│ } │
└──────────────────────────────┘
Permission Middleware
Checks that the JWT contains a specific action for a specific menu:
func Permission(menu string, action string) func(http.Handler) http.Handler {
// Extracts claims["permissions"][menu]
// Checks if the action slice contains the required action
// Returns 403 Forbidden if not found
}
// Usage in router:
r.With(auth.Permission("broadcast", "create")).
Post("/", handler.CreateBroadcast)
r.With(auth.Permission("users", "read")).
Get("/", handler.ListUsers)
Role Middleware
Coarse-grained checks based on the role claim:
// Allow only specific roles
func RequireRole(allowedRoles ...string) func(http.Handler) http.Handler {
// Checks claims["role"] is in allowedRoles
// Returns 403 Forbidden if not
}
// Shorthand for platform-level admin check
func RequirePlatformAdmin() func(http.Handler) http.Handler {
// Checks for "platform_admin" role
}
// Usage:
r.With(auth.RequireRole("admin", "manager")).
Delete("/{id}", handler.DeleteUser)
r.With(auth.RequirePlatformAdmin()).
Post("/tenants", handler.CreateTenant)
Tenant Ownership
Ensures a user can only access resources within their own tenant:
func RequireTenantOwnership(tc TenantContext, targetTenantID string) error {
// Platform admins bypass the check
if IsPlatformAdmin(tc) {
return nil
}
// Regular users can only access their own tenant
if tc.TenantID != targetTenantID {
return Forbidden("access denied")
}
return nil
}
Three layers of access control are applied in order: 1) JWT validation (is the token valid?), 2) Role/Permission middleware (is the user authorized for this action?), 3) Tenant ownership (does the resource belong to their tenant?). A request must pass all three to succeed.
6.8 Login Flow (Complete Trace) #
A step-by-step trace of POST /api/v1/auth/login from HTTP request to response,
showing every layer involved.
Step-by-Step
-
Gateway receives request
POST /api/v1/auth/login— login is a public route (no auth required), so the gateway proxies directly to auth-service without JWT validation. -
Handler: decode request
The auth handler decodes the JSON body into anAuthLoginstruct (email + password), then callsauthService.Login(). -
Service: verify credentials
- Find user by email in the database
- Compare password against stored bcrypt hash
- Check that the user’s
isActiveflag istrue
-
Service: fetch tenant features
HTTP call to admin-service:GET /internal/me/tenants/{id}to retrieve the tenant’s enabled feature flags (e.g.["broadcast","chatbot"]). -
Repository: generate tokens
- Build JWT with all claims (identity, tenant, permissions, versions, features)
- Sync version counters to Redis (
token_version,role_version,role_user_version) - Generate refresh token UUID, store SHA-256 hash in PostgreSQL
-
Handler: send response
- Set
refresh_tokencookie (HttpOnly, Secure, SameSite=Lax) - Set
tenant_idcookie - Return JSON:
{ "token": "eyJ..." }
- Set
Client Gateway Auth Service Admin Service Redis PostgreSQL
│ │ │ │ │ │
│ POST /auth/login │ │ │ │ │
│ {email, password} │ │ │ │ │
│────────────────────▶│ │ │ │ │
│ │ proxy (no auth) │ │ │ │
│ │─────────────────────▶│ │ │ │
│ │ │ │ │ │
│ │ │ SELECT user │ │ │
│ │ │ WHERE email = ? │ │ │
│ │ │──────────────────────────────────────────────────────▶│
│ │ │◀──────────────────────────────────────────────────────│
│ │ │ │ │ │
│ │ │ bcrypt.Compare() │ │ │
│ │ │ Check isActive │ │ │
│ │ │ │ │ │
│ │ │ GET /internal/me/ │ │ │
│ │ │ tenants/{id} │ │ │
│ │ │──────────────────────▶│ │ │
│ │ │ tenant features │ │ │
│ │ │◀──────────────────────│ │ │
│ │ │ │ │ │
│ │ │ SET versions │ │ │
│ │ │─────────────────────────────────────────▶│ │
│ │ │ │ │ │
│ │ │ INSERT refresh_token │ │ │
│ │ │──────────────────────────────────────────────────────▶│
│ │ │ │ │ │
│ │ Set-Cookie + JSON │ │ │ │
│ { token: "eyJ..." }│◀─────────────────────│ │ │ │
│◀────────────────────│ │ │ │ │
6.9 AES-256 Encryption #
pkg/crypto/aes.go
Sensitive credentials (e.g. Meta API tokens) are encrypted at rest in the database using AES-256-GCM (Galois/Counter Mode), which provides both confidentiality and authenticity. Each encryption uses a random nonce to ensure identical plaintexts produce different ciphertexts.
Configuration
| Parameter | Detail |
|---|---|
| Algorithm | AES-256-GCM (authenticated encryption with associated data) |
| Key source | Base64-encoded 256-bit key from environment variable |
| Nonce | Random 12-byte nonce generated per encryption via crypto/rand |
| Output format | base64(nonce + ciphertext) — nonce prepended before encoding |
Encrypt
func Encrypt(plaintext string, keyBase64 string) (string, error) {
// 1. Decode the base64 key
key, err := base64.StdEncoding.DecodeString(keyBase64)
if err != nil {
return "", err
}
// 2. Create AES cipher block
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
// 3. Create GCM wrapper (provides authenticated encryption)
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
// 4. Generate random nonce
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
// 5. Encrypt: nonce is prepended to ciphertext
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
// 6. Return base64-encoded result
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
Decrypt
func Decrypt(ciphertextBase64 string, keyBase64 string) (string, error) {
// 1. Decode base64 key and ciphertext
key, _ := base64.StdEncoding.DecodeString(keyBase64)
data, _ := base64.StdEncoding.DecodeString(ciphertextBase64)
// 2. Create AES cipher + GCM
block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCM(block)
// 3. Split nonce from ciphertext
nonceSize := gcm.NonceSize()
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
// 4. Decrypt and verify authentication tag
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err // tampered or wrong key
}
return string(plaintext), nil
}
Why AES-256-GCM? GCM provides both encryption and authentication in a
single pass. If the ciphertext is tampered with or the wrong key is used,
gcm.Open() returns an error. This prevents both data leakage
and silent data corruption.
Encrypt:
plaintext ──▶ AES-256-GCM(key, random_nonce) ──▶ base64(nonce + ciphertext)
│
▼
Stored in DB
Decrypt:
base64 string ──▶ decode ──▶ split(nonce | ciphertext)
│ │
▼ ▼
AES-256-GCM.Open(key, nonce, ciphertext)
│
▼
plaintext
1Engage Multitenant Backend — Codebase Guide — Authentication & Authorization