This document describes the real, as-implemented security posture of the DeraEdge platform — not an aspirational one. Where something is not yet done, it's stated plainly.
Passwords are hashed with bcrypt (golang.org/x/crypto/bcrypt, bcrypt.DefaultCost) in
backend/internal/services/password.go. Plaintext passwords are never logged or stored. There is
no password-strength meter enforced server-side beyond whatever internal/validation requires at
registration/reset time — review internal/validation directly for the current minimum before
relying on it for a compliance claim.
Sessions are opaque server-side tokens, not JWTs — see ARCHITECTURE.md for the reasoning. Concretely:
- On login/register, a random token is generated, its SHA-256 hash is stored in the
sessionstable (token_hash, unique), and the raw token is set as thede_sessioncookie (HttpOnly,SameSite=Lax,Path=/;Secure+Domain=SESSION_COOKIE_DOMAINonly in production). - Every request presenting the cookie is resolved by hashing the incoming token and looking up
the active (non-expired, non-revoked) session row —
internal/middleware.Auth. The database is the sole source of truth; nothing is trusted from the token's shape alone. - Sessions expire after 30 days (
SessionTTL) from creation. - Logout revokes exactly that one session (
sessions.revoked_at). - Password reset revokes all existing sessions for the user, not just the current one — a credential-stuffing or session-theft scenario is fully contained the moment the legitimate user resets their password.
- Because revocation is a single
UPDATE/lookup against a real table, there is no propagation delay and no denylist to keep in sync — the tradeoff (a DB round-trip per authenticated request) is accepted deliberately.
Double-submit-style, session-bound, but without a second cookie:
csrfTokenis returned in the JSON body of login, register, andGET /auth/me— never in a cookie, so it isn't automatically attached by the browser and must be deliberately read and forwarded by frontend JS.- The expected value is derived deterministically per-session as
sha256(sessionTokenHash + SERVER_SECRET)(internal/services.DeriveCSRFToken) — nothing extra is stored; it's recomputed on each check. - Every mutating request (
POST/PUT/PATCH/DELETE) made with a session cookie must include headerX-CSRF-Tokenmatching that derived value, compared withcrypto/subtle.ConstantTimeCompareto avoid timing side-channels. Mismatch or missing header →403 csrf_invalid. - Requests with no session cookie (login, register, the public contact form) are exempt — there is no session-bound secret to check yet. Those endpoints rely on CORS + per-IP rate limiting instead.
SERVER_SECRETis mandatory in production (config.Loadfails startup without it) and only auto-defaults to an insecure placeholder outside production.
internal/middleware.CORS allows only origins present verbatim in CORS_ALLOWED_ORIGINS
(comma-separated exact match, no wildcards, no subdomain pattern matching) and sets
Access-Control-Allow-Credentials: true — required because auth is cookie-based. Every deployment
environment must explicitly enumerate every origin (web, admin, and any preview/staging URL) that
needs credentialed access; nothing is allowed by default.
An in-process per-IP token bucket (golang.org/x/time/rate) guards auth endpoints
(/auth/register, /auth/login, /auth/forgot-password) and the public contact form
(/contact/partner-inquiry). Client IP is read from X-Forwarded-For if present, else the
connection's remote address.
Known limitation, stated plainly: this limiter's state is per-instance only, held in that
process's memory (internal/middleware/ratelimit.go). It is not shared across replicas. If the
backend is horizontally scaled to N instances, the effective global rate limit becomes
configured_limit × N, because each instance tracks visitors independently with no shared store.
A correct fix would be a shared store (Redis) backing the limiter; this has not been built. Until
then, treat the configured rate limit as a per-instance ceiling, not a global guarantee, when
sizing for abuse resistance.
POST /stripe/webhook reads the raw body (capped at 64KB via http.MaxBytesReader) and verifies
the Stripe-Signature header against STRIPE_WEBHOOK_SECRET before processing anything
(internal/stripe, internal/handlers/stripe_webhook.go). An invalid or missing signature is
rejected 400 before any DB write happens. Processing is idempotent: the stripe_events table
has a unique constraint on stripe_event_id; redelivering the same event is a no-op rather than
double-crediting an enrollment or double-sending emails — this was verified with an actual
duplicate-delivery integration test, not just code inspection. Live signature verification requires
a real Stripe-issued STRIPE_WEBHOOK_SECRET; the verification code path itself is exercised in
tests using stripe-go's synthetic signed-payload helpers with a test secret.
Three roles: public → student (automatic, only via a confirmed Stripe webhook on paid
enrollment — never client-initiated) → admin (manual only; there is no public admin registration
endpoint or path). The entire /admin/* route group is gated by
middleware.RequireRole(models.RoleAdmin), which re-reads the authenticated user's role from the
users table on every request via the session lookup — a client can never supply or influence
its own role. There is no role claim embedded in the session token to trust; the token is opaque
and carries no data at all.
Entitlement checks follow the same principle and are re-verified per request, never cached or inferred:
POST /enrollments/checkoutderives the charge amount fromcourses.price_centsserver-side; the client only supplies a course slug.POST /progress/lessons/:lessonId/completereturns403unless the caller currently has an active enrollment for that lesson's course.GET /certificates/:courseId/downloadre-checks 100% lesson completion againstlesson_progresson every download call, not just at certificate-issuance time.- Journal entry mutations (
PATCH/DELETE /journal/:id) return404(never403) when the entry isn't owned by the caller, to avoid leaking whether the entry exists at all.
Every mutating admin action writes a row to audit_logs
(actor_user_id, action, target_type, target_id, metadata as JSONB) via
internal/repository.AuditRepo.Log — role changes, enrollment grants/revokes, research
article create/update/publish/unpublish/delete, course updates. GET /admin/audit exposes this
log (paginated) to admins in the console. There is currently no tamper-evidence mechanism
(e.g. hash chaining) on the audit log itself — it's a plain insert-only table protected by normal
DB access controls, not a cryptographically verifiable ledger.
- No WAF or dedicated DDoS mitigation beyond whatever Cloudflare's platform defaults provide
at the DNS/CDN layer for
web/admin. There is no custom firewall ruleset, bot management, or challenge configured. - Rate limiting does not survive horizontal scaling correctly (see above) — no shared store.
- No 2FA/MFA for any role, including admin.
- No IP allowlisting for the admin console —
adminis reachable from anywhere on the public internet provided valid credentials; its only current protection beyond auth is that it's not linked fromweb's public navigation (obscurity, not access control). - No automated dependency/vulnerability scanning wired into CI as of this writing (verify against whatever CI config exists at the time you read this).
- No secrets manager integration — secrets are environment variables injected by the hosting platform (Render, Vercel), not pulled from a dedicated vault at runtime.
- Audit log is not tamper-evident — see above.
- Email deliverability/anti-spoofing (SPF/DKIM/DMARC for the sending domain) is a DNS-level
concern tracked in
DEPLOYMENT.md, not in this codebase.
None of these are silent — they're listed here so a reader doesn't assume more coverage than exists.