DEV Community

Amon Ochuka
Amon Ochuka

Posted on

Building a Secure Auth System in Go: JWT, 2FA, OAuth, Sessions, and Rate Limiting

Authentication seems simple at first glance. Then you add "remember me", OAuth, 2FA, and proper session revocation, and it gets complicated fast.

Wapi is a live streaming app tailored for African content, with a backend written in Go using Gin. Its auth system covers many production concerns, from token rotation to two-factor login. In this post I'll walk through the pieces, why each one matters, and the mistakes that are easy to make along the way.

The Token Strategy: Access and Refresh Tokens

Wapi uses the classic dual-token approach. Short-lived access tokens authenticate API requests, and longer-lived refresh tokens obtain new access tokens without forcing the user to log in again.

The login endpoint returns both tokens along with the user profile. When the access token expires, the client calls the refresh endpoint, which issues a new access token and a new refresh token.

That second part is the important one: refresh token rotation. Every time a refresh token is used, it is invalidated and replaced. This limits the damage if a refresh token is stolen, because the thief and the real user can't both keep using the same token.

Rotation also gives you theft detection. If someone presents a refresh token that has already been rotated, one of two things happened: a client retried badly, or a token was copied. Either way, the safe response is to revoke the entire session the token belonged to and force a fresh login.

JWTs Done Right

JSON Web Tokens are convenient and easy to misuse. A few rules go a long way:

  • Keep access tokens short-lived. Minutes, not hours. The shorter the lifetime, the smaller the window an attacker has with a stolen token.
  • Sign properly. Use a strong secret (HMAC) or an asymmetric key pair (RSA or ECDSA). Pin the expected signing algorithm when parsing, so a token can't claim a weaker one.
  • Never put sensitive data in the payload. A JWT is signed, not encrypted. Anyone holding the token can read its claims.
  • Validate everything. Check the signature, the expiry (exp), and the issuer and audience where they apply.
  • Store minimal claims. Usually just the user ID, a token ID (jti), and the expiry. Don't bloat the token, since it travels with every request.

In a Gin application this lives in middleware. It extracts the Bearer token from the Authorization header, validates it, and attaches the user ID to the request context so downstream handlers never have to parse tokens themselves.

There is one trade-off to be honest about. A pure JWT is stateless, so the server can't "un-issue" it before it expires. That is why access tokens must be short-lived, and why real revocation happens at the refresh token and session level. If you need instant revocation of access tokens, for example after a ban, you can check the token's session ID against a store on each request. That reintroduces state, so it's a deliberate trade of some of JWT's simplicity for control.

Session Management

Tokens alone aren't enough. Users and the system both need to be able to see and end sessions. Wapi exposes endpoints to list active sessions, revoke a specific session, and revoke all sessions at once.

This matters for several scenarios:

  • "Sign out of all devices" after a lost phone or a password change
  • Killing suspicious sessions once a login looks wrong
  • Meeting security expectations that users increasingly take for granted

To support it, you track each refresh token (or session ID) in the database along with metadata: device or user agent, IP address, creation time, and last-used time. Validating a refresh token then means checking that it exists and hasn't been revoked, and logging out means revoking it. The metadata is what turns a bare list of tokens into something a user can understand, such as "Chrome on Windows, last active two hours ago".

Store a hash of the refresh token rather than the token itself, the same way you would treat a password. If the database leaks, the stored values shouldn't be usable.

Two-Factor Authentication (TOTP)

Adding 2FA is one of the biggest security upgrades you can make. Wapi implements TOTP (Time-based One-Time Passwords), which works with Google Authenticator, Authy, 1Password, and similar apps.

The flow has four stages:

  1. Setup. An authenticated user requests 2FA setup. The server generates a secret and returns an otpauth:// URI, which the client renders as a QR code. The secret is stored (encrypted) against the user but not yet active.
  2. Verification. The user scans the QR code and enters the code their app displays. The server checks it against the stored secret, and only then switches 2FA on. This confirms the user's app is configured correctly before they can lock themselves out.
  3. Login with 2FA. When 2FA is enabled, a correct email and password no longer produce tokens. The server returns a short-lived challenge instead, and the user completes login by submitting a TOTP code. Only then are access and refresh tokens issued.
  4. Disabling. Turning 2FA off requires re-authentication, so someone with a briefly unattended session can't quietly weaken the account.

Two details deserve attention. First, the login challenge should be a single-purpose credential: short expiry, usable only to complete 2FA, and never accepted as an access token. Second, 2FA verification needs its own strict rate limit. A six-digit code has only a million possibilities, and brute-forcing that is realistic if attempts are unthrottled.

Finally, plan for account recovery. Users lose phones, so you'll want backup codes, generated at setup, stored hashed, and single-use.

OAuth 2.0 (Google and Facebook)

Social login is convenient for users and has its own pitfalls. Wapi uses the standard authorization code flow:

  1. The client hits the server's login endpoint for a provider, which redirects to the provider's authorization page.
  2. The user authenticates with the provider and consents.
  3. The provider redirects back to the server's callback endpoint with an authorization code.
  4. The server exchanges the code for tokens with the provider and fetches the user's profile.
  5. The server finds or creates a local account linked to that provider identity.
  6. The server issues its own access and refresh tokens.

Step 6 is where Wapi does something smart. Instead of putting tokens in the redirect URL, it redirects the browser back to the frontend with a short-lived one-time code, and the frontend exchanges that code for tokens through a separate endpoint. URLs end up in browser history, server logs, and Referer headers, so tokens don't belong there. A code that is single-use and expires in seconds is a much safer thing to leak.

A few OAuth details are easy to miss:

  • Validate the state parameter to protect against cross-site request forgery on the callback.
  • Use PKCE where the provider supports it.
  • Be careful when linking accounts by email. Only auto-link to an existing local account if the provider confirms the email is verified. Otherwise an attacker can register at a provider with someone else's address and take over their account.

Rate Limiting for Abuse Prevention

Auth endpoints are prime targets for credential stuffing and brute-force attacks. Wapi applies different limits depending on the endpoint:

Endpoint type Limit Keyed by
Signup, login, password reset 10 per minute IP
OAuth initiation 5 per minute IP
2FA verification (suggested) Stricter than login, for example 5 attempts per 15 minutes IP and user or session

The limiter is built on golang.org/x/time/rate, with buckets stored per key. When a limit is hit, the API returns 429 Too Many Requests with a clear error code so clients can back off gracefully.

An in-memory limiter is fine for a single instance, but note what happens when you scale out. Each instance counts separately, so your effective limit is multiplied by the number of instances. When you run more than one, move the counters to Redis, using a sliding window or token bucket, so every instance shares the same view. Also remember that IP-based limits are blunt: users behind a shared NAT share a bucket, and attackers rotate IPs. That matters even more for a mobile-heavy audience, where carrier-grade NAT can put many real users behind one IP. That is why the 2FA limit also keys on the user or session.

Security Best Practices to Keep in Mind

  • Password hashing. Never store plaintext passwords. Use bcrypt, argon2, or scrypt with a sensible cost factor, and raise it over time as hardware improves.
  • Email verification. Verify addresses before allowing sensitive actions.
  • Password reset. Use time-limited, single-use tokens delivered by email, and invalidate existing sessions when a reset completes.
  • Account enumeration. Return the same response whether or not an email exists, for login errors and for password reset requests.
  • CORS. Lock allowed origins down to the ones you actually use.
  • Secure headers. Add standard security headers in production.
  • Token storage on the client. HttpOnly, Secure, SameSite cookies protect tokens from theft via XSS in a way localStorage can't. The trade-off is that cookie-based auth needs CSRF protection, which SameSite helps with. If you send tokens in an Authorization header instead, keep them in memory and accept the refresh-on-reload cost.

Putting It All Together

A robust auth system isn't about implementing every feature at once. It's about layering defenses. Start with solid password authentication and short-lived JWTs. Add refresh token rotation and session management so users can end sessions remotely. Layer on 2FA for accounts that matter, add OAuth for convenience, and wrap everything in rate limiting.

What stands out in Wapi's implementation is that these pieces are wired up and work together rather than existing as isolated features. The routes are organized logically, the middleware is reusable, and each flow closes the gaps the previous layer leaves open. It's a solid reference for anyone building auth in Go.

Top comments (0)