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.