DEV Community

Chukwuemeka Maduekwe
Chukwuemeka Maduekwe

Posted on

Securing Your App with Access and Refresh Tokens: A Practical Guide

Securing Your App with Access and Refresh Tokens: A Practical Guide

My Journey: From Naive to Secure Authentication
Let me be honest about how I handled authentication early in my career. Like many developers, I started with approaches that seemed reasonable but had serious flaws.

The Sessions I Built (And Why They Failed)
Approach #1: The Stateless Session
I would normally issue a token when users logged in, store it in session storage, and call it a day. The moment they closed the tab, poof! Session gone, everyone is happy (actually only the developer — myself). My server never tracked anything. Clean and simple, right? Wrong. Users couldn’t stay logged in across devices. Opening the app on mobile after using it on desktop meant logging in again. The experience was frustrating, and I knew something was off.

Approach #2: The Single Token
Then I switched gears. One token per user, stored server-side with an expiration that refreshed on each API call. Users could reuse this token across multiple devices. For some projects, I’d create a JWT signed with a session ID, valid for 180 days. So once a user is created a token or what I call session is generated and inserted into the users table (wrong place to be to begin with).

Better, but this approach was still problematic and worse — incriminating. If that token ever get leaked or stolen, the attacker or malicious user would have access for months. Revoking it meant forcing users to log in again everywhere. There had to be a better way I said to myself? That better way exists, and it’s what major platforms like GitHub, Google, Spotify and many more websites use: the access and refresh token pattern. Once I understood it, I slept better knowing my users’ accounts were truly protected, at least for the moment.


Understanding the Two-Token System
Rather than drown you in theory, let me explain this through real-world analogies that finally made it click for me.

Access Token: Your Temporary Badge
Think of an access token as biometric verification at a bank. When you walk up to a teller or customer service rep, they scan your fingerprint. For the next few minutes you’ll be with them while you’re conducting business with that specific bank staff, they don’t need to re-verify you. They know who you are. So for every question/request you ask/make they do not need re-scan your fingerprint before providing an answer.

But the moment you dare move to a different teller who wasn’t part of that initial verification, the verification process starts over — fraustrating? Yes, I know, but simpley put your “access” has expired.

So technically, an access token is a short-lived credential (typically 5–30 minutes) that proves you’re authorized to access protected resources. It’s issued by an authentication server and contains minimal information about the user, in most cases, its usually just a user ID and a token version. Each API request includes this token in the Authorization: Bearer <token> header, more like a standard way in my opinion to pass this token to the server.

Refresh Token: Your Driver’s License
Now imagine getting your driver’s license. You take tests, prove you can drive, and receive a license valid for several years. When a police officer stops you, they don’t reassess your driving skills (though in most cases I think they should), they simply verify your license is valid.

When your license expires after, let say 5 years, you don’t retake all the tests. You renew it. Your name, date of birth, and other core details stay the same, but you get a new expiration date and license number. The old number is effectively revoked.

That’s exactly how refresh tokens work. They’re long-lived credentials (days to months) used exclusively to obtain new access tokens. When your access token expires, you present your refresh token to get a new one, no password required. The refresh token is only sent to the authentication server, never to resource APIs.

Just a brief note before we go ahead, I would like to point out the difference between an authentication and resource server.

Authentication or Auth Server: Think of it as the bouncer at a club Its only job is to check who you are. You show your ID (username/password, OTP, token). If everything is correct, the bouncer gives you a stamp or wristband (a token). It does NOT serve you drinks, food, or anything else, it only verifies your identity. Examples would Auth0, Keycloak, Firebase Auth, or your own login service. In simple terms its more like a “Who are you?” server that assigns tokens.

Resource Server: Think of it as the bartender inside the club. It doesn’t check your identity; it checks the stamp the bouncer gave you. If your stamp/token is valid, it lets you access the resources inside (Which in this case would be your data, API routes, files, etc.). If the stamp has expired or is fake, the bartender says “No access.”. Example would be an API that returns user profile, payments, dashboard data, etc. We can call this an “Are you allowed to see this?” server that verifies tokens and gives data.

Also note that in today’s world a single server can serve both purposes as we would see in this guide. This was one of the excuse I gave my self initially when I started reading about access/refresh tokens that only big companies use this pattern since it entails running two servers simultaneously


Why Two Tokens Instead of One?

Back to business? You might be still be wondering why this is better than a single token? right, I get that. This is the question I wrestled with for weeks. If both tokens are sent with requests (one in a cookie, one in a header, which we would get to), what’s the point? The answer lies in blast radius and revocation granularity.

  A[User Logs In] --> B[Auth Server Issues Both Tokens]
  B --> C[Access Token: 15 min lifetime]
  B --> D[Refresh Token: 90 day lifetime]
  C --> E[Sent with Every API Request]
  D --> F[Sent Only to Token Endpoint]
  E --> G{Token Stolen via XSS?}
  G --> | Single Token System | H[Attacker Has Access for 90 Days]
  G --> | Two Token System | I[Attacker Has Access for 15 Minutes Max]
  F --> J{Refresh Token Stolen?}
  J --> K[Token Rotation Detects Theft]
  K --> L[Revoke Entire Token Family]
Enter fullscreen mode Exit fullscreen mode

The Security Advantages:

The Security Advantages of two tokens

The two-token pattern also dramatically improves user experience. Users stay logged in for weeks without re-entering credentials, while you maintain tight security controls.

Building the System: A Deep Dive

Just to reiterate — everything in this tutorial is framework-agnostic and can be implemented in any tech stack. For context, my personal stack of choice is MongoDB, Rust (Axum), Next.js (React) + TypeScript, and Redis. However, for this article, I’ll be focusing mainly on the backend implementation. In a separate article, we’ll dive into the frontend consumption and the MongoDB schema design.

Having said that, A single server acts as both authentication and resource server, which is the reality for most applications. I would try as much to present this in a Q/A Session format to improve understanding and practicality.

Do You Need to Track Refresh Tokens?

Yes, absolutely. Here’s what you must store:

// models/session.rs

use mongodb::bson::{DateTime, doc, oid::ObjectId};
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Session {
    #[serde(rename = "_id", default, skip_serializing_if = "Option::is_none")]
    pub id: Option<ObjectId>,      // MongoDB document ID
    pub token: String,             // SHA-256 hash of the token - no need to indicate token_hash, we don't do password_hash
    pub user_id: String,           // Reference to User's ID in our users collection
    pub family_id: String,         // Groups tokens from same login
    pub create_date: DateTime,     // When the token was created
    pub expiry_date: DateTime,     // When the token expires
    pub revoked: bool,             // If the token has been revoked
    pub used_at: Option<DateTime>, // Detects reuse attacks, easy way to sparate re-used tokens from revoked ones
    pub device_info: String,       // Optional just for logging purposes
}
Enter fullscreen mode Exit fullscreen mode

This gives you:

  1. Security: Hash tokens before storage (like passwords, but with SHA-256)
  2. Revocation: Immediately invalidate compromised tokens
  3. Audit trail: Track when and how tokens are used
  4. Theft detection: Identify when revoked tokens are reused

What do you mean by Token Rotation:

In simple terms its like a key defense that every time someone uses a refresh token:

  1. Issue a new access token and a new refresh token
  2. Mark the old refresh token as “used”
  3. If a used token is presented again, someone is replaying it — revoke the entire token family
1. Access token expires
2. POST /refresh with refresh_token cookie
3. Look up token_hash
4. Found, not used, not expired
5. Mark old token as used_at = now
6. Create new refresh token
7. Return new access token + Set new refresh_token cookie

> Note: If attacker tries to reuse old token...

2. POST /refresh with OLD token
3. Look up token_hash
4. Token marked as used!
5. Revoke entire token family
6. 401 Unauthorized - Re-login required  
Enter fullscreen mode Exit fullscreen mode
// utils/auth_utils.rs

pub fn generate_jwt_token(secret: &String, sub: &String, domain: &String, purpose: TokenPurpose) -> Result<(String, Claims), String> {
    let scope = match purpose {
        TokenPurpose::AccessToken => "openid profile read:posts write:comments".to_string(),
        TokenPurpose::EmailVerification => "email-verification".to_string(),
        TokenPurpose::PasswordRecovery => "password-recovery".to_string(),
    };

    let claims = Claims {
        scope,
        sub: sub.to_string(), // User ID (never changes) + token version (Increment on password change/sign out from all devices to invalidate all tokens)
        aud: domain.to_string(), // Audience
        iss: domain.to_string(), // Issuer
        iat: Utc::now().timestamp(), // Issued at
        jti: Uuid::new_v4().to_string(), // Unique token ID
        exp: (Utc::now() + get_jwt_expiry(purpose)).timestamp(), // Expiry
    };

    match encode(
        &Header::new(Algorithm::HS256), // or RS256 if you use RSA keys (recommended in prod)
        &claims,
        &EncodingKey::from_secret(&secret.as_ref()),
    ) {
        Ok(r) => Ok((r, claims)),
        Err(_) => Err("Failed to Generate Token".to_string()),
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice what’s not included: name, email, role. These can change in your database, but JWTs are stateless. Including them creates a security hole that the token won’t reflect updates until it expires. Keep it minimal and lightweight.

Refresh Token (Opaque Random String):

pub fn generate_refresh_token() -> String {
    // For URL-safe Base64, we need: bytes = ceil(char_length * 3 / 4)
    // let byte_size = (char_length * 3 + 3) / 4;
    // let mut raw_bytes = vec![0u8; byte_size];


    // let mut raw_token = [0u8; 48]; // without the vec! the value "48" if coming from a variable must be a constant
    let mut raw_bytes = vec![0u8; 48]; // 384 bits of pure entropy
    // ? Note: rand::thread_rng() is deprecated
    rand::rng().fill_bytes(&mut raw_bytes); // Cryptographically secure

    general_purpose::URL_SAFE_NO_PAD.encode(raw_bytes)

    // Truncate to exact length
    // let encoded = general_purpose::URL_SAFE_NO_PAD.encode(raw_bytes);
    // encoded.chars().take(char_length).collect()
}
Enter fullscreen mode Exit fullscreen mode

This produces 384 bits of randomness, i.e 2³⁸⁴ possible combinations. Even checking a trillion tokens per second, brute-forcing would take longer than the age of the universe.

Why Not UUID?

Many developers reach for UUID v4, but it’s weaker:

xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx

  1. The 4 is always 4 (version indicator): 4 bits wasted
  2. The y is constrained to 8, 9, a, or b: 2 bits wasted
  3. Hyphens add zero entropy
  4. Result: Only 122 bits of actual randomness

That’s 2²⁶² times fewer possibilities than our 384-bit approach. While 122 bits is still large, authentication tokens deserve maximum unpredictability.

Hashing Tokens: SHA-256, Not Bcrypt

In a previous tutorial you might have seen me use Bcrypt to hash user’s password? Yes. You hash passwords with bcrypt or Argon2 because even weak passwords need computational slowness to resist brute-force attacks. Even though we have other mechanism in place to reduce the effectiveness of brute-force attacks by implementing account lockouts. Tokens are different and you’ll see why in a moment.

// utils/auth_utils.rs

// Hash any refresh token (raw Base64 string)
pub fn hash_token(token: &str) -> String {
    // Decode the Base64 back to raw bytes first!
    let raw_bytes = general_purpose::URL_SAFE_NO_PAD.decode(token).expect("Invalid refresh token format"); // or return Result<String>

    let mut hasher = Sha256::new();
    hasher.update(&raw_bytes);
    hex::encode(hasher.finalize())
    // → 64-char hex string ready for DB storage
}
Enter fullscreen mode Exit fullscreen mode

Use SHA-256 because:

  1. Tokens already have 384 bits of entropy — they’re unguessable by design
  2. Database lookups need to be fast because you’re checking this hash on every refresh request
  3. Deterministic: Same input always produces the same hash for database queries, unlike password hashing.

Slow hashing like bcrypt would only hurt performance without adding much security.

The Cookie Question: Is It Safe?

Here’s where confusion peaks. If refresh tokens are in HttpOnly cookies, aren’t they sent with every request? Yes, and that’s completely normal. Every production system that I know of works this way.

Cookie Security Breakdown

// utils/auth_utils.rs

pub fn save_refresh_token_to_cookie(refresh_token: &String) -> String {
    Cookie::build(("SSID", refresh_token))
        .path("/auth/refresh") // Only sent to refresh endpoint not every request
        .secure(true) // Only sent over HTTPS
        .http_only(true) // JavaScript cannot read it
        .same_site(cookie::SameSite::Strict) // Blocks CSRF attacks
        .max_age(CookieDuration::days(REFRESH_TOKEN_EXPIRES_IN)) // cookie Expiry
        .build()
        .to_string()
}
Enter fullscreen mode Exit fullscreen mode

This configuration protects against:

  1. XSS: HttpOnly prevents JavaScript access, even if an attacker injects code.
  2. CSRF: SameSite=Strict ensures cookies only go to your domain
  3. Network sniffing: Secure flag mandates HTTPS
  4. Accidental exposure: Path restriction limits where the cookie travels

Why Not Just Use One Token If Both Are Sent?

This question kept me up at night. Here’s the critical insight I missed: The point isn’t which token travels, it’s how long each one lives and what happens when stolen, moreover refresh only goes to the auth/refresh endpoint.

Why Not Just Use One Token If Both Are Sent?

The Flow in Practice

Let me walk you through exactly what happens in a real session.

What should my Initial Login look like?

1. Enter credentials
2. POST /auth/login
3. alidate Login credentials
4. Find user in by email in database
5. User valid; retruns user data
6. Check if email is verified (returned user data)
7. Get device_info if available in header (optional during session collection insert)
8. Generate refresh token (random)
9. Insert refresh into session collection
10. Generate refresh_token cookie
11. Check if user info is in redis cache 
12. Add user to redis cache (user_id, role, token_version)
13. Generate signed access_token claims (note that you can choose not to have token_version in claims sub)
14. Set cookie (refresh token) in header
15. Add bearer to header (access token) in header
16. Return headers, bearer, name (optional) avatar(optional) to login handler
Enter fullscreen mode Exit fullscreen mode
// services/auth/login_service.rs

pub async fn login_service(state: &AppState, payload: LoginPayload, headers: HeaderMap) -> Result<LoginServiceResponse, String> {
    if let Err(err) = payload.validate() {
        return Err(err.to_string());
    }

    let user_repo = UserRepo::new(&state.database);
    let user = user_repo.find_by_mail(&payload.email).await.map_err(|_| "Invalid Username/Password".to_string())?;

    if let Err(_err) = bcrypt::verify(&payload.password, &user.password) {
        return Err("Invalid Username/Password".to_string());
    }

    if user.email_verified_at.is_none() {
        return Err("Kindly verify your email to proceed".to_string());
    }

    let device_info = auth_utils::get_device_info(&headers);
    let session_repo = SessionRepo::new(&state.database);

    let refresh_token = session_repo
        .insert(&device_info, &user.user_id)
        .await
        .map_err(|_err| "Suspicious activity detected".to_string())?;

    let refresh_token_cookie = auth_utils::save_refresh_token_to_cookie(&refresh_token);

    let cache_user = match redis_utils::get_user(&state.redis, &state.config.app.id, &user.user_id).await {
        Ok(cache_user) => redis_utils::AuthUser {
            role: user.role,
            id: user.user_id.to_string(),
            token_version: cache_user.token_version,
        },

        Err(_) => redis_utils::AuthUser {
            id: user.user_id.to_string(),
            role: user.role,
            token_version: 0,
        },
    };

    redis_utils::set_user(&state.redis, &state.config.app.id, &cache_user).await?;

    let access_token_sub = json!(AccessTokenSubject {
        ssid: cache_user.id,
        token_version: cache_user.token_version,
    })
    .to_string();

    let (access_token, _) = auth_utils::generate_jwt_token(&state.config.app.secret, &access_token_sub, &state.config.client.host, TokenPurpose::AccessToken)
        .map_err(|_| "Failed to generate access token".to_string())?;

    let mut headers = HeaderMap::new();
    headers.insert(header::SET_COOKIE, HeaderValue::from_str(&refresh_token_cookie).unwrap());
    headers.insert(header::AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {}", access_token)).unwrap());

    Ok(LoginServiceResponse {
        headers,
        access_token,
        name: user.name,
        avatar: user.avatar,
    })
}
Enter fullscreen mode Exit fullscreen mode

The refresh token cookie travels along automatically (because SameSite allows same-domain requests), but it’s ignored by resource endpoints. Only the /auth/refresh endpoint cares about it.

So now, what happens when my access token expires?

Generating a new access token is quite similar to the login service with minor tweaks to improve security and better safeguard user’s account. Also,

1. GET or POST /auth/refresh
2. Extract current refresh_token in user's cookie
3. Fetch refresh_token info from session collection
4. Verify that refresh_token has not  expired, been used, or revoked 
5. Generate new refresh_token tied to same family_id and user (notice we did not enforce device info to be the same)
6. Get user data, so we can update user's role in our cache if it has changed (better way to handle it would have been at the time of role update, but this acts like a fallback in case of a direct DB update)
7. Update redis cache
8. Generate access token sub with cached token_version
9. Generate signed access_token claims based on the token sub
10. Generate refresh_token cookie
11. Insert Cookie and Bearer into Headers
12. Return headers and access_token to refresh handler
Enter fullscreen mode Exit fullscreen mode
// services/auth/login_service.rs

let user_repo = UserRepo::new(&state.database);
let session_repo = SessionRepo::new(&state.database);
let refresh_token = auth_utils::get_cookie(&headers, "SSID").ok_or("Missing authorization credentials, Kindly signin to regain access".to_string())?;
let session = session_repo
    .get_by_token(&refresh_token)
    .await
    .map_err(|_| "Token verification failed: Invalid or mismatched token.".to_string())?;

let refresh_has_expired = Utc::now().timestamp_millis() > session.expiry_date.timestamp_millis();
if session.used_at.is_some() || session.revoked || refresh_has_expired {
    session_repo.revoke_token_family(&session.family_id).await?;
    return Err("Token refresh request is blocked by our security policy".to_string());
}

let refresh_token = session_repo
    .refresh_existing_token(&auth_utils::get_device_info(&headers), &session)
    .await
    .map_err(|_err| "Something went wrong. Token refresh failed".to_string())?;

let user = user_repo.find_by_user_id(&session.user_id).await.map_err(|_err| "Failed to retrieve User's info")?;

let cache_user = match redis_utils::get_user(&state.redis, &state.config.app.id, &user.user_id).await {
    Ok(cache_user) => redis_utils::AuthUser {
        role: user.role,
        id: user.user_id.to_string(),
        token_version: cache_user.token_version,
    },

    Err(_) => redis_utils::AuthUser {
        id: user.user_id.to_string(),
        role: user.role,
        token_version: 0,
    },
};

redis_utils::set_user(&state.redis, &state.config.app.id, &cache_user).await?;

let access_token_sub = json!(AccessTokenSubject {
    ssid: cache_user.id,
    token_version: cache_user.token_version,
})
.to_string();

let (access_token, _) = auth_utils::generate_jwt_token(&state.config.app.secret, &access_token_sub, &state.config.client.host, TokenPurpose::AccessToken)
    .map_err(|_| "Failed to generate access token".to_string())?;

let refresh_token_cookie = auth_utils::save_refresh_token_to_cookie(&refresh_token);

let mut headers = HeaderMap::new();
headers.insert(header::SET_COOKIE, HeaderValue::from_str(&refresh_token_cookie).unwrap());
headers.insert(header::AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {}", access_token)).unwrap());

Ok(RefreshServiceResponse { headers, access_token })
Enter fullscreen mode Exit fullscreen mode

Key point: The old refresh token is marked as “used.” If someone tries to reuse it (indicating token theft), the entire token family gets revoked. Both the attacker and the legitimate user must log in again. Annoying for the real user? Yes, but it instantly contains the breach.


Addressing Common Concerns

Can I Store the Access Token in an HttpOnly Cookie Too?

Yes, but this breaks compatibility with: Mobile apps (iOS, Android), Desktop applications, Command-line tools (curl, Postman), Third-party API integrations, GraphQL clients, Server-to-server calls

These clients need to read the access token and manually include it in the Authorization header. HttpOnly cookies are invisible to application code, making this impossible.

The only scenario where access tokens in HttpOnly cookies work is when you control 100% of the client code like a traditional server-rendered web app. For any modern architecture, it’s likely to break.

*When the User Refreshes the Browser, How Do They Get the Access Token?
*

We have three approaches:

  1. Memory + Automatic Refresh (Most Secure): Access token lives only in JavaScript memory (or React state in my case), So on page refresh, it’s gone and the client immediately calls “/auth/refresh” using the HttpOnly cookie, to which the Server returns a new access token and the User never notices the delay
  2. LocalStorage (Accept the XSS Risk): Store access token using localStorage.setItem(‘access_token’, token). Persists across page refreshes. Risk: If an attacker injects JavaScript, they can read it Mitigate with Content Security Policy and Subresource Integrity
  3. Encrypted Session Cookie (Seems like what GitHub uses from my investigation): Single HttpOnly cookie called user_session that contains an encrypted blob with both access and refresh tokens. Every request, the server decrypts and validates the token. If access token expired, the server silently issues a new one and the frontend JavaScript never touches any token.

Personally, I prefer Option 1 for SPAs and mobile apps. Option 3 should work well for traditional web apps with server-side rendering.

Does Every Refresh Call Issue a New Access Token?
Yes, and that’s intentional. Every call to /auth/refresh issues a brand-new access token (new jti, new iat, new exp), a brand-new refresh token, marks the old refresh token as “used”, and stores the new refresh token in the database. This rotation enables theft detection. If a revoked token suddenly reappears, you know something is wrong.

Will Users Stay Logged In Indefinitely?

Sort of, depending on how you look at it. If your refresh token is valid for 90 days and the user logs in at least once before it expires, they get a new 90-day refresh token. This creates a rolling expiration window. As long as the user stays active (even if it’s just one API call every 89 days), they remain logged in indefinitely; until:

They explicitly log out, You revoke their token family (e.g., password change or signout from all devices) or you detect suspicious activity and revoke tokens. I believe this is exactly how Spotify, and Gmail work. You’re not re-entering your password every month unless something unusual happens.


Final Thoughts
The access and refresh token pattern isn’t just about following best practices, it’s about limiting damage. When (not if) something goes wrong, you want the blast radius to be measured in minutes, not months.
It took me years to fully appreciate this pattern. I hope this guide shortcuts that journey for you. Build systems that let you sleep at night, knowing that even if an attacker gets in, they won’t get far.

If you found this helpful, drop a comment with your own authentication war stories. We’ve all been there, and we all learned the hard way. I’m also putting together a GitHub repo with the full codebase.

Happy Hacking ✌

Top comments (0)