Techadamia Backend - Security Implementation Guide
This document describes the security controls actually implemented in the Techadamia backend, verified against the source. It is meant to be an accurate reference, not an aspirational one: features that do not exist are listed under "Known Limitations / Not Yet Implemented" rather than described as if present.
Overview
The API uses server-side session authentication (no JWT), a double-submit cookie CSRF defense, per-endpoint rate limiting, request body size limits, hardened media upload validation, security response headers, an origin CORS allowlist, and a role/status-based authorization model. It is production-ready for single-instance deployments; multi-instance deployments should configure Redis so rate-limit state is shared.
Note
At a glance: server-side sessions (no JWT), double-submit CSRF, bcrypt hashing, per-IP rate limiting, a strict CORS allowlist, hardened media uploads, and security headers — all validated at startup. Known gaps are listed honestly in §17.
Relevant source files:
internal/middleware/middleware.go- CORS, rate limiting (in-memory), body size cap, security headers, CSRF, auth/role middleware.internal/middleware/ratelimit_redis.go- Redis-backed rate limiter.internal/service/auth.go- login, bootstrap, sessions, password hashing.internal/utils/security.go- CSRF token generation and cookie/header names.internal/api/handlers.go- cookies, CSRF token issuance, media validation, bootstrap handler, authorization guards.cmd/main.go- configuration validation, HTTP server timeouts, session cleanup, trusted-proxy validation.
1. Authentication (Server-Side Sessions)
Location: internal/service/auth.go, internal/api/handlers.go,
internal/middleware/middleware.go
There are no JWTs and no refresh tokens. A session is a random
UUID v4 (github.com/google/uuid) persisted in the sessions table with a
7-day TTL (sessionTTL = 7 * 24 * time.Hour).
Login flow (Service.Login)
- Look up the user by email.
- Verify the password with bcrypt (see section 2).
- Require
user_status = active; otherwise returnErrUserNotActive(the handler responds403 account not active). - In a single transaction:
DeleteAllUserSessions(user_id)- enforces a single active session per user (logging in elsewhere invalidates prior sessions).AddSession(...)- inserts the new session withexpires_at.UpdateLastLogin(...)- updateslast_login_at.
- On success the handler sets:
session_idcookie - HttpOnly, path/,Max-Agematching the TTL.csrf_tokencookie - readable by JS (not HttpOnly), see section 4.
Session transport
Each authenticated request resolves the session id via extractSessionID:
- The
session_idcookie, if present; otherwise - An
Authorization: Bearer <session_id>header.
RequireAuthMiddleware parses the id, loads the session + user
(GetSessionAndUser), and requires user_status = active (otherwise
403 account not active). A missing/invalid/unknown session yields 401.
Logout (Logout handler)
Deletes the session row (DeleteSession) and clears both the session_id and
csrf_token cookies (Max-Age = -1).
Session cleanup
Expired sessions are purged on startup and on an interval governed by
SESSION_CLEANUP_INTERVAL (default 1h, cmd/main.go
startSessionCleanup). DeleteExpiredSessions removes rows where
expires_at < NOW().
Cookie configuration
sessionCookieConfig() reads:
SESSION_COOKIE_SECURE- required (validated at startup); must be true in production (APP_ENV=production).SESSION_COOKIE_SAMESITE-lax(default behavior),strict, ornone.nonerequiresSESSION_COOKIE_SECURE=true.SESSION_COOKIE_DOMAIN- optional cookie domain.
2. Password Hashing & Login Timing Mitigation
Location: internal/service/auth.go
- Passwords are hashed with bcrypt at
bcrypt.DefaultCoston register and bootstrap, and verified withbcrypt.CompareHashAndPasswordon login. - Anti-enumeration timing mitigation: when the email is unknown
(
pgx.ErrNoRows), login runs abcrypt.CompareHashAndPasswordagainst a pre-computed dummy hash before returningErrInvalidCredentials, so the response time of "user not found" matches "user found, wrong password". - The handler returns the same generic
401 invalid credentialsfor both unknown email and wrong password.
Password / input rules (internal/api/validator.go)
- Password: minimum length 8, maximum 72 (bcrypt's input limit). There are no complexity, breach, or reuse rules.
- Username: 3-50 characters, restricted to
[a-zA-Z0-9._-](URL-safe — usernames appear in public profile paths), starting and ending alphanumeric. - Optional phone number: must be exactly 10 digits when provided.
Password reset & email verification tokens (internal/service/tokens.go)
Both flows use the same single-use token model:
- Tokens are 32 random bytes (hex); the database stores only the
SHA-256 hash (
password_reset_tokens,email_verification_tokens), so a database leak does not yield usable links. - TTLs: reset 1 hour, verification 24 hours. Expired rows are purged by the background cleanup sweep alongside expired sessions.
- One outstanding token per account — requesting again deletes the previous token. Redeeming burns every token for that account.
- Enumeration-safe endpoints:
POST /password-reset/requestandPOST /verify-email/resendreturn byte-identical responses whether or not the email matches an account (and, for resend, whether or not it is already verified). - Redeeming a reset token (
POST /password-reset/confirm) sets the new bcrypt hash and revokes every session for the account. PUT /me/password(authenticated password change) verifies the current password, then atomically updates the hash, deletes outstanding reset tokens, revokes all sessions, and issues one fresh session to the caller.- Email delivery is via SMTP (
internal/mail). WhenSMTP_HOSTis unset the mailer is disabled: links are written to the server log instead, and reader registrations activate without verification (development mode — do not run production this way).
3. Admin Bootstrap
Location: internal/api/handlers.go (BootstrapAdmin),
internal/service/auth.go (Service.BootstrapAdmin)
POST /admin/bootstrap creates the very first admin and is designed to be safe
to leave registered:
- If
BOOTSTRAP_TOKENis unset, the endpoint is disabled (403). - The
X-Bootstrap-Tokenheader is compared toBOOTSTRAP_TOKENwithcrypto/subtle.ConstantTimeCompare; mismatch returns401. - In a transaction,
CountAdminsis checked: if an admin already exists it returns409(ErrAdminAlreadyExists). Otherwise the new admin is created and immediately set toactive(admins skip the pending flow).
Because it refuses once any admin exists, it is effectively one-time.
4. CSRF Protection (Double-Submit Cookie)
Location: internal/middleware/middleware.go (CSRFMiddleware),
internal/utils/security.go, internal/api/handlers.go
- On login the server generates a token via
utils.GenerateCSRFToken- 32 random bytes (crypto/rand) encoded as base64url (base64.RawURLEncoding). - The token is delivered in the
csrf_tokencookie (JS-readable, not HttpOnly, so the client can echo it back). - For state-changing methods the client must send the same value in the
X-CSRF-Tokenheader.
Enforcement rules
CSRF is checked only when all of the following hold:
- Method is
POST,PUT,PATCH, orDELETE(GET/HEAD/OPTIONS skip). - The request is not Bearer-authenticated (Bearer requests are exempt - they are not subject to ambient cookie auth).
- A
session_idcookie is present (cookie-authenticated requests).
When enforced:
- Missing/empty
csrf_tokencookie ->403 csrf token missing. - Missing header or value mismatch ->
403 csrf token invalid. The comparison usescrypto/subtle.ConstantTimeCompare.
5. Authorization Model
Location: internal/middleware/middleware.go, internal/api/handlers.go,
internal/service/*
Roles and statuses
- Roles (
user_role):admin,author,reader. Self-registration creates anauthor(admin-approved) by default, or areaderwithrole=reader(activated by email verification, or immediately when SMTP is not configured). Readers get the full social surface (comments, likes, follows, messages, media upload for avatars) but cannot author posts. The first admin is created via bootstrap. - Statuses (
user_status):pending,active,suspended,deleted.
Middleware gates
RequireAuthMiddleware- valid session andstatus = active.OptionalAuthMiddleware- populates the same context when a valid session is present but never rejects; used on public endpoints that personalize for logged-in viewers (liked_by_me,following). It grants no access.RequireAuthorMiddleware- roleauthororadmin.RequireAdminMiddleware- roleadminonly.
Post ownership
For post update/delete, the handler passes requireOwnership = role != admin.
Non-admins must own the post (verified in the service via VerifyPostOwnership);
admins bypass the ownership check. Failing the check returns 403.
Comment ownership & moderation
Editing a comment requires being its author (403 otherwise). Deleting
requires author or admin. PUT /comments/:id/hide is admin-only
(moderation: hidden comments leave public lists but remain recoverable, unlike
the soft delete). Notification reads are ownership-scoped in SQL — marking
another user's notification is a no-op surfaced as 404.
Private-data exposure rules
- Public profiles (
GET /users/:username) never expose email, phone number, or account status, and resolve active accounts only. - Message threads and notification inboxes are always scoped to the
authenticated user in the query's
WHEREclause, never by client-supplied IDs.
Admin self-protection guards
- Self-target guard:
DeleteUserandUpdateUserStatusreturn400if the admin targets their own account ("cannot delete/change your own ..."). - Last-active-admin guard: removing or deactivating the final active admin
returns
409(ErrLastAdmin), enforced viaguardLastAdmin+CountAdmins. - Setting a user's status to
deletedperforms a soft delete; any non-activestatus change also wipes that user's sessions.
6. Rate Limiting
Location: internal/middleware/middleware.go,
internal/middleware/ratelimit_redis.go, internal/api/routes.go
A fixed-window limiter keyed by client IP (c.ClientIP()).
- In-memory (default): a mutex-guarded map with periodic eviction of expired entries. Not shared across instances.
- Redis (when
RATE_LIMIT_REDIS_URLis set): a Lua script doingINCR+PEXPIRE(andPTTL) for atomic per-window counting across instances.
Per-endpoint specs (env / default)
| Env | Default | Applies to |
|---|---|---|
RATE_LIMIT_LOGIN |
10/1m |
POST /login, POST /admin/bootstrap, password-reset request/confirm, verify-email (+resend), PUT /me/password |
RATE_LIMIT_REGISTER |
10/1m |
POST /register |
RATE_LIMIT_MEDIA |
30/1m |
POST /media, GET /media/:key |
RATE_LIMIT_ADMIN |
60/1m |
admin routes |
RATE_LIMIT_WRITE |
60/1m |
author post routes + social writes (comments, likes, follows, messages, PUT /me) |
Note: the shipped
.env.exampleoverrides some of these (e.g.RATE_LIMIT_LOGIN=5/5m); the values above are the in-code defaults used when the env var is unset.
Response headers
Every rate-limited response carries:
X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset(seconds until window reset)
When the limit is exceeded the response is 429 Too Many Requests with a
Retry-After header (seconds). If the limiter backend errors, the request
fails closed with 503.
7. Request Body Size Limits
Location: internal/middleware/middleware.go (MaxBodyBytesMiddleware),
internal/api/routes.go
- A global middleware wraps request bodies for
POST/PUT/PATCH/DELETEwithhttp.MaxBytesReader, capped atMAX_REQUEST_BODY_BYTES(default 1 MiB / 1048576). - The
/mediaroute is exempt from the global cap and instead enforces its own larger limit,MEDIA_MAX_BYTES(default 10000000, ~10 MB), viahttp.MaxBytesReaderinside the upload handler.
8. Media Upload Validation
Location: internal/api/handlers.go (UploadMedia, loadMediaConfig)
POST /media (author/admin only) validates uploads in layers:
- Size - bounded by
MEDIA_MAX_BYTESviahttp.MaxBytesReaderand an explicitfile.Sizecheck; over-limit ->413. - Content sniffing - the first 512 bytes are inspected with
http.DetectContentType. The detected MIME must be inMEDIA_ALLOWED_MIME(defaultimage/jpeg,image/png,image/webp); otherwise415. - Declared type - the multipart part's
Content-Type(when present) is parsed and must also be in the allowlist; otherwise415. Both the sniffed and declared types are checked. - Storage - the file is saved under
MEDIA_STORAGE_DIRwith a randomuuidfilename plus a safe extension derived from the detected MIME. The database stores the relativestorage_key(the filename), not an absolute path and not the user-supplied filename. If the DB insert fails, the saved file is removed to avoid orphans for that request. - Orphan cleanup - a background sweep (
startMediaGC, everyMEDIA_GC_INTERVAL, default 1h) deletes media not linked to any live post once it is older thanMEDIA_ORPHAN_GRACE(default 24h), removing both the row and the backing file. The grace period protects freshly uploaded media that is not yet attached to a post.
9. Security Headers
Location: internal/middleware/middleware.go (SecurityHeadersMiddleware)
Set on every response:
| Header | Value | Notes |
|---|---|---|
X-Content-Type-Options |
nosniff |
always |
X-Frame-Options |
DENY |
always |
Referrer-Policy |
strict-origin-when-cross-origin (default) |
REFERRER_POLICY |
Permissions-Policy |
geolocation=(), microphone=(), camera=() (default) |
PERMISSIONS_POLICY |
Strict-Transport-Security |
built from HSTS_* |
only if HSTS_MAX_AGE is set |
Content-Security-Policy |
from CONTENT_SECURITY_POLICY |
only if set |
HSTS is opt-in: when HSTS_MAX_AGE is provided, includeSubDomains and
preload are appended based on HSTS_INCLUDE_SUBDOMAINS / HSTS_PRELOAD.
10. CORS (Cross-Origin Resource Sharing)
Location: internal/middleware/middleware.go (LoadCORSConfigFromEnv,
CORSMiddleware)
- Allowlist only.
CORS_ALLOWED_ORIGINSis required at startup. Requests whoseOriginis not on the list get no CORS headers; an unknown-origin preflightOPTIONSis rejected with403. CORS_ALLOWED_METHODS(defaultGET,POST,PUT,DELETE,OPTIONS) andCORS_ALLOWED_HEADERS(defaultContent-Type,Authorization,X-CSRF-Token).CORS_ALLOW_CREDENTIALScannot be true together with a wildcard (*) origin - startup config loading rejects that combination.
11. HTTP Server Timeouts
Location: cmd/main.go
The http.Server is configured with:
ReadHeaderTimeout: 10sReadTimeout: 30sWriteTimeout: 30sIdleTimeout: 120sMaxHeaderBytes: 1 MiB (1 << 20)
These bound slow-client / slowloris-style exposure.
12. Trusted Proxies & Client IP
Location: cmd/main.go (validateTrustedProxies), internal/api/routes.go
TRUSTED_PROXIES (comma-separated IPs/CIDRs; validated at startup) is passed to
Gin's SetTrustedProxies. This determines whether X-Forwarded-For is trusted
for c.ClientIP(), which feeds rate limiting and access logs. Default: none
trusted (SetTrustedProxies(nil)), so behind a proxy this must be set for
correct per-client rate limiting.
13. Soft Deletes & Data Model
Location: internal/sql/schema.sql, migrations, service layer
- Users and posts use status enums plus a
deleted_attimestamp; deletion is a soft delete (status set todeleted), not a row removal. - Sessions and join rows (post/category, post/media) cascade with their parents.
- Database migrations run on startup under a
pg_advisory_lock(internal/migrate/migrate.go) so concurrent replicas cannot apply them simultaneously.
14. Request ID Tracking & Access Logging
Location: internal/utils/request_id.go,
internal/middleware/middleware.go
RequestIDMiddlewareaccepts or generates anX-Request-Id(UUID v4), stores it in context, and echoes it on the response.- A custom slog handler stamps the request id onto log records.
AccessLogMiddlewareemits structured logs (method, path, status, latency, client IP) instead of Gin's default logger.
15. Configuration Validation (Fail Fast)
Location: cmd/main.go (loadConfig)
Startup validates configuration and exits non-zero on any problem, including:
- Required:
DATABASE_URL,APP_PORT,MEDIA_STORAGE_DIR(must be writable),SESSION_COOKIE_SECURE. APP_ENV=productionrequiresSESSION_COOKIE_SECURE=true.SESSION_COOKIE_SAMESITE=nonerequiresSESSION_COOKIE_SECURE=true.- CORS allowlist present and consistent with credentials.
- Rate-limit specs parse;
RATE_LIMIT_REDIS_URL(if set) is a valid URL. MEDIA_MAX_BYTESpositive;MEDIA_ALLOWED_MIMEentries are valid MIME types.TRUSTED_PROXIESentries are valid IPs/CIDRs.HSTS_*values well-formed;SESSION_CLEANUP_INTERVALa valid duration.
16. Production Checklist
-
APP_ENV=production -
SESSION_COOKIE_SECURE=true(required in production) -
SESSION_COOKIE_SAMESITEchosen deliberately (nonerequires Secure) -
CORS_ALLOWED_ORIGINSset to real frontend origins (no wildcard with credentials) -
TRUSTED_PROXIESset to your reverse proxy IPs/CIDRs - Rate-limit specs reviewed for expected traffic
-
RATE_LIMIT_REDIS_URLconfigured if running multiple instances -
MEDIA_MAX_BYTESandMEDIA_ALLOWED_MIMEset appropriately -
HSTS_MAX_AGEset if served over HTTPS -
BOOTSTRAP_TOKENset for first deploy, then unset after the admin exists -
SMTP_*configured — without it, password-reset links only land in server logs and readers activate without email verification -
APP_PUBLIC_URLset to the real frontend origin (emailed links point here) - Database and media backups arranged externally
- Graceful shutdown verified (SIGTERM drains in-flight requests)
17. Known Limitations / Not Yet Implemented
The following are genuinely absent from the current codebase. They are listed so this document does not overstate the security posture:
- No CI pipeline (no automated build/test/lint on push).
- No metrics, tracing, or observability beyond structured logs (no
Prometheus
/metrics, no OpenTelemetry). - No audit log of security-relevant actions (status changes, deletions, logins).
- No password complexity, breach, or reuse checks beyond the minimum length of 8.
- No account lockout / progressive backoff on repeated failed logins (only IP-based rate limiting).
- No 2FA / MFA.
- No JWT or refresh tokens. Authentication is server-side sessions only.
- No per-user cap on SSE connections (
GET /events); each connection is cheap (one buffered channel), but a hostile authenticated user could open many. - No user-level blocking for messaging/comments — moderation is admin-side (hide/delete/suspend).
In-memory rate limiting across instances
Without Redis, each instance keeps its own counters, so the effective limit
scales with the number of instances. Configure RATE_LIMIT_REDIS_URL for a
shared limit.
Document Version: 2.0 Last Updated: 2026-07-01