❮ Writing

Building an Auth Provider in Go

Why I built my own auth provider, the security mindset behind it, layered rate limiting, handler-to-repo layout, login flow, CTEs, session binding, and audit on TimescaleDB.

I built an auth provider in TypeScript first, then ported the same rules to Go. Stack is Echo, Postgres with sqlc/pgx, Redis, goose, Argon2id, RSA JWTs, TimescaleDB for audit. This post is about how I think about auth and how the pieces fit together, not a line-by-line walkthrough of the repo.

Why I wrote this

I went looking for a good blog on building an auth provider. Not “how to sign a JWT in 20 lines.” Not an OWASP checklist pasted into Markdown. Something from someone who shipped login, refresh, recovery, and session revocation under abuse and had to live with the choices.

Most of what I found stops at happy path: hash the password, return a token, call it a day. Almost nothing on timing leaks, refresh reuse, device binding, email normalization per provider, or keeping audit data out of application logs. I wrote this because I wanted that post to exist, even if I had to write it myself.

Mindset: security first

Before I touch a handler I ask where I can break it and where someone can abuse it.

What if the refresh token leaks? What if someone replays an old one? What if they hammer forgot password with a list of emails? What if they register [email protected] and [email protected] as two accounts? What if the login response tells them which emails exist? What if a locked account gets a different error than a wrong password?

Auth is mostly a state machine with adversaries. The happy path is the easy part. I design for the failure and abuse paths first, then wire login so it still feels normal when nothing is wrong.

If the happy path works but one of those breaks, it is still a bug.

How the code is structured

I split things into three layers and keep middleware thin.

Handlers bind the request, validate it, call the service, map the result to HTTP. They do not query Postgres directly and they do not decide lockout rules.

Service owns status branches, token mint, session creation, outbox enqueue, and the order those happen in. This is where isValidUser logic lives, not in the handler.

Repository runs sqlc queries. Most writes are CTE chains: one round trip, atomic inside Postgres. Repos map pgx.ErrNoRows to typed errors the service can branch on.

Middleware only verifies JWTs and attaches identity to context. Crypto lives in pkg/. Vertical slices under core/auth, repos under core/user and core/session, infra for db and cache. The layout is not the lesson. The point is one path so a new route cannot skip normalization or store refresh tokens in plaintext.

Errors at the edge

Every handler returns the same envelope: error flag, data, timestamp. Internal errors become generic messages on the outside. Login never says “user not found” or “account locked.” Recovery never says “email not registered.” The service returns typed errors; the handler maps them to the right HTTP status and the structured JSON body. Logs can differ. Responses cannot.

Rate limiting

Rate limiting starts early in the middleware stack and it is never one layer. IP-based limiting runs on every auth route. On top of that, each route can attach attribute-based limiting with its own key and window. Same generic middleware everywhere; the route declares what to count.

IP layer (always). Registered once on the auth group. Key is client IP from the request. Stops blind volume from one host against login, refresh, register, recovery, all of it.

Attribute layer (per route). One reusable middleware, configured at registration time with a key function and a limit profile. The key function pulls whatever matters for that endpoint: normalized email from the body, session id from a refresh token, user id from a verified JWT, device id, and so on. Login does not get this layer. Refresh does. Forgot password and resend get normalized email plus the global IP cap. Register might key on normalized email too. Each route picks the attribute that actually stops abuse for that flow without copying middleware code.

// global on auth routes — IP on everything
authGroup.Use(rateLimit.IP(redis, ipProfile))

authGroup.POST("/login", loginHandler)
authGroup.POST("/refresh", refreshHandler, rateLimit.ByKey(redis, refreshProfile, keyFromSessionID))
authGroup.POST("/forgot-password", forgotHandler, rateLimit.ByKey(redis, recoveryProfile, keyFromNormalizedEmail))

Route names, profiles, and extractors shortened for readability.

POST /login gets IP limiting from the group and nothing else. Refresh and recovery add ByKey on top with whatever attribute fits that flow.

ByKey is the same function every time. Only the profile and the extractor change. Redis increments a counter keyed by route name plus the extracted value. Over limit: 429 with the same structured error envelope as everything else.

Login is IP only. Hammering one account from many IPs is a different problem (lockout in Postgres handles repeated bad passwords on a valid user). Recovery is where email-keyed limits matter: same outward response whether the address exists, but after the limit you still return success and send nothing. Attribute limits and uniform responses work together.

Not a single bucket for the whole API. Not copy-pasted if count > N in every handler.

Login, end to end

HTTP layer first, then the service function as it actually runs.

flowchart LR
  REQ([Request])

  subgraph HTTP["HTTP edge"]
    direction TB
    MW[Middleware stack]
    RL[IP rate limit]
    BV[Bind + validate]
    MW --> RL --> BV
  end

  subgraph SVC["Service.Login"]
    direction TB
    N[Normalize email] --> L[(Lookup)] --> C[ComparePassword]
    C -->|fail| Q{User exists?}
    Q -->|no| D[Dummy DB]
    Q -->|yes| F[Record attempts]
    D --> IC[Invalid credentials]
    F --> IC
    C -->|ok| U{Unverified?}
    U -->|yes| P[Purpose token]
    U -->|no| T[Session + JWT pair]
  end

  subgraph OUT["Response"]
    direction TB
    ERR[[Structured error]]
    OK[[LoginResponse DTO]]
  end

  REQ --> HTTP
  HTTP -->|429 / invalid| ERR
  HTTP -->|ok| N
  IC --> ERR
  P --> OK
  T --> OK

  classDef lane fill:#0d1118,stroke:#4d7eb8,stroke-width:1.5px,color:#c8ddf5
  classDef fail fill:#181014,stroke:#d96570,stroke-width:1.5px,color:#f2b6bb
  classDef pass fill:#0d1512,stroke:#6aab7a,stroke-width:1.5px,color:#c8efd0
  classDef start fill:#101014,stroke:#555,color:#ddd
  class MW,RL,BV,N,L,C,Q,D,F,IC,U lane
  class ERR,IC fail
  class OK,P,T pass
  class REQ start

Diagram simplified for readability. Logging, tracing, repository layer, rate-limiting internals, and single-flight are not shown.

Middleware stack. Recovery catches panics. Body limit stops oversized payloads. Gzip is on for responses. Context timeout middleware caps how long the handler chain can run. Logger runs after that so request ids exist. Security headers go out on every response. Rate limiting comes right after: IP on every auth route, then the route-specific key if that endpoint defines one. Login registers IP only. Refresh adds a session-scoped key. Recovery adds normalized email.

Bind and validate together. One struct, one pass: required fields, email format, password length. Binding or validation failure returns the structured error immediately. No service call.

Service: normalize, lookup, compare password first. Raw email never reaches the repo. isValidUser is not checked before the compare and does not return invalid credentials.

normalizedEmail := s.normalizer.Normalize(data.Email)
user, err := s.userRepo.GetByNormalizedEmail(ctx, normalizedEmail)
if err != nil && !errors.Is(err, result.ErrUserNotFound) {
    return nil, err
}

var pass *string
if user != nil {
    pass = user.PasswordHash
}
isValidPassword, err := hash.ComparePassword(pass, data.Password)
if err != nil {
    return nil, err
}

if !isValidPassword {
    if user == nil {
        _ = s.userRepo.RecordFailedAttempts(ctx, dummyParams) // zero-row round trip
    } else {
        _ = s.userRepo.RecordFailedAttempts(ctx, generated.RecordFailedLoginAttemptParams{
            UserID: user.ID, ...
        })
    }
    return nil, result.ErrInvalidCredentials
}

isValidUser := user != nil &&
    (user.Status == generated.AccountStatusesActive ||
        user.Status == generated.AccountStatusesUnverified) &&
    (user.LockedUntil == nil || user.LockedUntil.Before(time.Now().UTC()))

Error handling, audit writes, and device binding omitted. Do not copy-paste.

Password compare always runs. No row for that email? pass stays nil, compare hits a dummy hash anyway. Same Argon2 work as when the user exists but typed the wrong password. This only holds if ComparePassword unconditionally runs the full Argon2 computation when pass is nil — comparing against a pre-computed dummy hash rather than short-circuiting. A nil early-return is a timing oracle that leaks whether the email exists.

Invalid credentials only on failed password. User missing → dummy DB call, no counter bump. User exists → real RecordFailedAttempts (may set locked_until). Same JSON either way.

isValidUser is computed after that branch. It gates whether a successful password can move on to tokens. When isValidUser is false despite a correct password (locked, banned, or unknown status), the service returns the same ErrInvalidCredentials that maps to the same HTTP body and status code as a wrong password. The lock state does not leak outward.

Outward JSON is identical in every failure case. Timing stays in the same ballpark: dummy hash when there is no user, dummy DB when there is no row to update, real DB when a row exists and the password was wrong.

Success splits on status when password passed and isValidUser is true. Unverified users get a purpose token and expiry in the response DTO. No session row, no refresh pair:

if user.Status == generated.AccountStatusesUnverified {
    purposeToken, _ := s.jwt.GeneratePurposeToken(user.ID, jwt.PurposeVerification)
    return &LoginResponse{
        Status: user.Status,
        PurposeToken: purposeToken,
        PurposeTokenExpiresAt: &purposeTtl,
    }, nil
}

Error checks omitted for readability.

Active users get a v7 session id, access/refresh pair, refresh hash stored, TTLs on the DTO:

sid, _ := uuid.NewV7()
tokenPair, _ := s.jwt.GenerateTokenPair(user.ID, sid, user.TokenVersion)
s.sessionRepo.Create(ctx, generated.CreateSessionParams{
    UserID: user.ID, SessionID: sid,
    RefreshHash: hash.SHA256(tokenPair.RefreshToken),
    ExpiresAt: s.jwt.GetRefreshExpiration(),
})
return &LoginResponse{
    Status: user.Status,
    AccessToken: tokenPair.AccessToken,
    RefreshToken: tokenPair.RefreshToken,
    AccessExpiresAt: &accessTtl,
    RefreshExpiresAt: &refreshTtl,
}, nil

Error checks omitted for readability.

sqlc row types stop at the service boundary. The handler wraps LoginResponse in the uniform envelope. No password hashes or lock counters in that struct.

Security practices I follow

These are the rules I keep across every endpoint. Break one and you do not have auth, you have a login form.

Secrets at rest are hashed. Refresh tokens, verification codes, reset tokens: SHA-256 in the database, plaintext shown to the client once.

Purpose tokens are not session tokens. Email verify and password reset get a short-lived JWT with its own signing keys and no refresh path. Full access/refresh pairs only when account status warrants it. Verify and resend routes take a purpose header, not Bearer access.

Refresh rotates. Every successful refresh replaces the stored hash. Replay an old refresh: revoke every session for that user, bump token version, cache the version in Redis so existing access tokens die without a Postgres read on each request.

Recovery endpoints get abused. Forgot password and resend verification return the same body whether the email exists. IP limit plus normalized-email attribute limit on those routes. After the attribute limit, still return success, send nothing.

Enumeration stays closed. Login: same JSON, same timing. Dummy hash on compare when the user does not exist. Dummy DB on failed password when there is no row. Real RecordFailedAttempts when the row exists and the password was wrong. Forgot password: same string always. No outbox row when the email is unknown; the client still gets the same JSON. Duplicate email on register can be a 409 if the product wants that. Login and recovery stay vague.

Session device binding. On login I store what the client sent: device id, IP, user agent, country. Refresh and session-bound calls re-check those fields. Mismatch means theft: revoke the whole session family, bump token version, generic error outward. Mismatch metadata goes to audit, not zap logs. I do not log raw IP, geo, or user agent in application output.

Logout and password reset revoke sessions and bump token version for the same reason refresh reuse does.

Sessions

Access tokens are short. Refresh tokens tie to a session row: user id, session id, refresh hash, expiry, revoked flag. That snapshot at login is the session fingerprint.

Redis holds session:revoked markers and cached token versions for fast middleware checks. Postgres remains source of truth. Redis going away costs latency, not correctness.

Email normalization

Not lowercase and done. Each host family gets its own rules before any lookup. I use a normalizer library that knows provider behavior instead of one regex for the whole internet.

Gmail and Googlemail are the same inbox: @gmail.com and @googlemail.com collapse to one canonical address. Strip everything after + in the local part. Remove dots in the local part, because Google ignores them. [email protected] and [email protected] must resolve to the same key.

Microsoft is a different rule set, not Gmail rules pasted onto Outlook. For one user, @outlook.com, @hotmail.com, @live.com, and the other consumer domains Microsoft aliases together should normalize the same way Microsoft treats them. Do not strip Gmail dots on a Hotmail address.

Yahoo, iCloud, Proton, corporate SMTP, and the rest each have their own tables in the normalizer. When abuse shows up in audit data, extend the rule for that provider instead of bolting a global hack onto login only.

Baseline for everyone: trim whitespace, punycode on the domain, lowercase where the provider is case-insensitive. Store normalized_email and unique on that column. Repos never see raw input.

One round trip to Postgres

Login and recovery flows accumulate steps fast: bump attempts, maybe lock, write token, insert outbox. The naive version is multiple queries or a transaction open while Go executes logic. I push that into SQL.

For most paths I use one statement, often a CTE chain, instead of BEGIN/COMMIT in the app. Postgres runs the whole thing atomically. If a step’s UPDATE … RETURNING hits zero rows, the statement fails.

Wrong password: increment attempts and set locked_until in one update:

UPDATE users SET
  failed_login_attempts = CASE
    WHEN failed_login_attempts + 1 >= $max THEN $max
    ELSE failed_login_attempts + 1 END,
  locked_until = CASE
    WHEN failed_login_attempts + 1 >= $max
    THEN now() + make_interval(mins => $lock_mins)
    ELSE locked_until END
WHERE id = $user_id;

Illustrative only. Production uses sqlc CTEs with more branches. Counter stays at $max on lock rather than resetting to 0 — resetting to 0 would give the attacker a fresh window after every lockout expiry.

Verify account, reset password, consume token + flip status: same pattern in sqlc as CTEs. One params struct in, one outcome out.

Response DTOs

sqlc row types stop at the service. Handlers return LoginResponse, TokenResponse, and similar with tokens, expiry, status. No password hashes, lock counters, or internal ids the client does not need. Uniform envelope on top: error flag, data, timestamp.

Outbox

Token row and outbox row commit together (CTE or single transaction). Outbox failure rolls back the token. Handler returns after one commit.

The worker does not poll. Trigger on insert:

CREATE OR REPLACE FUNCTION notify_outbox()
RETURNS trigger AS $$
BEGIN
  PERFORM pg_notify('outbox_insert', NEW.id::text);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

Trigger wiring omitted for readability.

Go code LISTENs on outbox_insert and sends mail. No plaintext codes in API responses.

Event audit

Security events append to an event_logs table. Insert only. No updates, no deletes from the app. Postgres with TimescaleDB: hypertable on time, compression policies so months of history do not eat the disk.

Audit rows carry event type, user id, session id, timestamps, and coded reason enums. Sensitive request metadata used for session binding stays out of logs and lands here if it needs to be retained, under whatever retention policy you set in the database layer.

Tests that caught bugs

Service tests with mocked repos: wrong password locks, dummy-hash and dummy-DB path when user missing or locked, device mismatch revokes all sessions, unverified login gets purpose token not refresh, refresh reuse revokes all, reset invalidates old sessions.

Integration: verify then first session, old refresh fails after rotate, refresh from different device fingerprint triggers family revoke, logout then refresh fails, purpose route rejects access token, forgot password same body for known/unknown email, verify CTE rolls back on bad code.

I care more about failure branches than login RPS.

Code review nos

Specific login errors for UX: no on the public API.

Store refresh tokens to debug: no.

Skip refresh rotation on mobile: no.

Skip device binding because “users travel”: loosen policy, do not remove the check.

Share signing keys between purpose and access JWTs: no.

Log IP or user agent in request logs for debugging: no. Audit table if you need the history.

Closing

Consistent rules across endpoints matter more than which hash function you pick. Hash long-lived tokens at rest, rotate refresh, bind sessions to device context, revoke the family on mismatch, separate purpose from access, normalize email hard, one SQL statement per decision, DTOs at the edge, outbox in the same commit with pg_notify, append-only audit on TimescaleDB without logging sensitive request metadata to stdout.

Code snippets in this post are trimmed for readability. Error handling, audit inserts, device context, and some middleware wiring are left out on purpose. They show shape and order, not a drop-in implementation. Do not copy-paste them into production.

That is the post I could not find when I started. Hopefully it saves someone a week of reading JWT tutorials that stop halfway.