I spent a week on authentication. It nearly broke me.
Last time, I explained what Adsloty is and why I'm creating it. This time, I want to talk about something less exciting: authentication.
I knew handling authentication wouldn't be easy. This isn't just a simple blog or a to-do app. Adsloty deals with real money. Sponsors pay for ad slots, and writers receive payments. When money flows through your platform, the phrase "it works on my machine" isn’t enough. You must consider what happens when someone tries to break in.
I learned this lesson the hard way. Here’s what went wrong and how I fixed it.
The brute force problem
My first login setup was very basic. It included an endpoint that accepts an email and password, checks if they match, and returns a token. There were no limits or tracking, which meant someone could easily try many password guesses without any barriers.
To fix this, I made some changes to the system. I added a counter in the database to track failed login attempts. Each incorrect password increases this counter. After five failed attempts, the account locks for 30 minutes. If the user logs in successfully, the counter resets.
I also ensured users could easily regain access. If they reset their password, the account unlocks right away. Locking users out of their accounts without a way to recover is a sure way to lose them.
// after a failed login attempt
let locked = db::user::record_login_failure(&state.db, user.id).await?;
if user.is_locked() {
return Err(AppError::ForbiddenWithReason(
"Account locked. Please wait 30 minutes or reset your password to unlock.".to_string(),
));
}
// on successful login, reset the counter
db::user::record_login_success(&state.db, user.id, None).await?;
This solution isn’t complicated and doesn’t include advanced features like machine learning to find suspicious activity. However, it works, and I could implement it in a day. I can always add more sophisticated features later.
Token theft and the rotation fix
This one scared me more.
I originally used refresh tokens that lasted for 30 days. When you logged in, you would receive a short-lived access token and a long-lived refresh token. You used the refresh token to get new access tokens when the old ones expired. This is standard practice.
The problem was that if someone stole the refresh token—through a hacked device or a leaked log, for example—they could have access for 30 days without you knowing.
The solution was token rotation. Now, every time someone uses a refresh token, it gets canceled right away, and a new one is issued. The old token becomes invalid as soon as it is exchanged. So, if an attacker steals a token and tries to use it after the real user has already used it, it will fail. The time an attacker can use a stolen token shrinks from 30 days down to the time it takes the real user to make their next request.
// find the existing refresh token
let token_hash = TokenGenerator::hash_token(&refresh_token_plain);
let token = db::token::find_refresh_token(&state.db, &token_hash)
.await?
.ok_or(AppError::Unauthorized)?;
// revoke it immediately — it's now dead
db::token::revoke_refresh_token(&state.db, &token_hash, Some("Used for refresh")).await?;
// issue a brand new one
let (new_refresh_token_plain, expires_at) = TokenGenerator::generate_refresh_token();
let new_refresh_token_hash = TokenGenerator::hash_token(&new_refresh_token_plain);
db::token::create_refresh_token(
&state.db,
user.id,
&new_refresh_token_hash,
expires_at,
None,
None,
)
.await?;
Three steps. Use it, kill it, replace it. If an attacker replays the old token, step one fails because step two already ran.
It made the token process more complex, with more database updates and more possible issues to handle. But for a platform that deals with payments, it’s essential.
The cookie bug that only existed in production
This situation was frustrating because everything worked well on my local machine.
I store authentication tokens in HttpOnly cookies. For production, these cookies must have the Secure flag so they are only sent over HTTPS. That makes sense. However, I didn't realize at first that a cookie marked Secure won't be set on localhost when using plain HTTP.
After I deployed and tested the login, it just didn’t work. There were no errors. The server set the cookie, but the browser ignored it silently. I spent too much time looking at the network tabs before I figured out what was wrong.
The solution was to adjust the cookie settings based on the environment. In production, I use Secure, HttpOnly, and SameSite=Lax. In development, I use the same settings but without the Secure flag. It took only a few lines of code to fix, but it took hours to find the problem. It was the kind of bug that makes you doubt your career choices.
fn create_auth_cookies(
access_token: &str,
refresh_token: &str,
user_role: &str,
is_production: bool,
) -> HeaderMap {
let secure_flag = if is_production { "; Secure" } else { "" };
let same_site = "; SameSite=Lax";
let http_only = "; HttpOnly";
let access_cookie = format!(
"accessToken={}; Max-Age=3600{}{}{}",
access_token, http_only, secure_flag, same_site
);
// ...
}
And then where it's called:
let is_production = state.config.env == Environment::Production;
let headers = create_auth_cookies(&access_token, &refresh_token, "writer", is_production);
One boolean. That's all it took. But finding out I needed it cost me an afternoon.
Two users, two completely different flows
Adsloty has two types of users: writers and sponsors. They use the same login form, but the process is very different for each.
Writers need to provide details about their newsletter, like its name, URL, and subscriber count. They must also complete an onboarding process before they can list ad slots. Sponsors only need to give their company name to get started.
At first, I tried to use a single registration endpoint with an if statement to handle both types. This approach became messy within an hour. There were different validation rules, different database tables, and different onboarding steps. Trying to put everything into one handler made the code hard to read.
So, I separated the registration into two endpoints, one for writers and one for sponsors. I created different profile tables in the database. The JWT includes the user's role, and every protected route checks if the user is logged in and allowed to access that area. A sponsor cannot enter the writer’s dashboard, and a writer cannot access campaign management.
// router — clean separation, no branching
pub fn auth_public_router() -> Router<AppState> {
Router::new()
.route("/register/writer", post(register_writer))
.route("/register/sponsor", post(register_sponsor))
.route("/login", post(login))
// ...
}
And the JWT generation that carries the role:
let role_str = match user.role {
UserRole::Writer => "writer",
UserRole::Sponsor => "sponsor",
UserRole::Admin => "admin",
};
let access_token = jwt_service.generate_access_token(user.id, &user.email, role_str)?;
This method requires more code, but it keeps each path clear and easy to understand. If something goes wrong in the writer registration process, I know exactly where to find the issue.
Logging everything
One decision I made early on that I'm thankful for is logging every authentication event. This includes each successful login, every failed attempt, every token refresh, every email verification, and every password reset. All this information goes into an auth_events table, which includes timestamps, user IDs, IP addresses, and whether each event succeeded or failed.
db::token::log_auth_event(
&state.db,
AuthEventParams {
user_id: Some(user.id),
event_type: "login_failed",
ip_address: None,
user_agent: None,
success: false,
failure_reason: Some("Invalid credentials"),
},
)
.await?;
Every auth function ends with one of these. It's repetitive to write, but when someone emails you saying, 'I can't log in,' you'll be glad you have it.
I haven't needed this data for a real incident yet. But when you're handling other people's money, saying "I don't know what happened" is not acceptable. When something goes wrong, and it always does, I want to know exactly what happened and when.
What I'd tell someone building auth for a payments app
Don't underestimate this task. It's not something you can do in a weekend. Every shortcut you take will likely cause problems when real money is involved.
Lock accounts after failed login attempts. Rotate your tokens regularly. Make your cookie configuration aware of the environment from the start. Keep user types separate instead of complicating things with conditionals. Also, log more information than you think you need.
None of this is new or exciting. But making sure you do it correctly, in the right order, and without any gaps is where you will spend your time.
Next time, I’ll probably write about integrating Stripe Connect and the challenges of managing webhooks in a two-sided marketplace. That has been its own adventure.
Until then.
Top comments (0)