Understanding Authentication: Know How It Works Before You Outsource It
When I started to learn about authentication, I kept seeing the same advice
Don’t build authentication yourself, just use Auth0, Firebase or another auth provider
And honestly ? That’s good advice… mostly.
Authentication is challenging to implement securely and can be easily mistaken, which can expose users and systems to serious risks. But there is a deeper problem here: outsourcing something you don’t fully understand makes you blind to its failures.
This post is not a tutorial for beginners. It’s a deep dive into how authentication works, the problems it solves, how it differs from authorization, what token systems like JWT do, and what best practices look like. This is for developers who want to understand the moving parts before picking tools.
What is Authentication?
Authentication is the process of verifying the user is who they claim to be. That is it, but the way you do that and the way you maintain the user’s identity throughout the requests is the part that you need to understand clearly.
Practically, authentication usually involves:
-
Accepting a set of credentials (username + password, OAuth, PassKeys).
-
Validating those credentials across a trusted source (usually, a database contains information about users).
-
Issuing something that can be used to verify the identity across subsequent requests.
It is not just the login screen; It is the handshake between a system and a user that says, “I know who you are, and I trust you.”
But how is this different from authorization?
Authentication and Authorization are understood easily by asking the questions,
-
Authentication → “Who are you?”
-
Authorization → “What are you allowed to do?”
It doesn’t matter how strictly you control access to action X if you don’t have a solid authentication system in place. Without reliably knowing who the user is, any authorization checks you perform are meaningless. Authentication is the foundation on which Authorization is built.
Therefore, throughout the rest of this article, we will focus only on Authentication.
How Authentication is commonly implemented?
Early on, when I was learning about authentication, I hit that fork on the road: session-based authentication vs token-based authentication.
The decision wasn’t hard, not because I was an expert but because the demands of modern web architecture made it for me.
Token-based authentication is the standard today, but to understand why, it is essential to understand what came before.
Session based authentication
This was my first exposure to authentication. I built a server-rendered app, used a login form to authenticate the user, and sent a session cookie on success.
Here is how it worked:
-
The server validated the login credentials by checking them with a trusted source (usually the database containing information about the users).
-
It generates a session ID, stores it on the server side, and returns it to the client side via a HttpOnly cookie.
-
Then, the browser sends that cookie with every request that needs validation (authentication)
-
The server extracts the session ID from the cookie and cross-checks the record on the server for that session ID, confirming whether it is valid or not.
In my case, I stored sessions on a relational database. Each login created a new row in the sessions
table, and I returned the primary key as the session ID, which is straightforward, secure, and easy to revoke (delete the row containing the session ID)
It worked beautifully …, until it didn’t
As my app evolved, I had several primary considerations: every authentication request required a database read to confirm that the session was a valid one. This process is fine with a few users, but once I added more features (features that require authentication), more users, and more frequent API calls, those extra reads accumulate quickly.
What about storing the session in the memory?
That will work until there are enough users to exhaust the memory or until I have to scale horizontally. Now, I will need to synchronize sessions between servers, which requires using Redis. However, setting up Redis for a session that can be easily stolen from your browser without your knowledge is not worth it.
This system would suck even more if I let a single user have multiple sessions, AKA letting more devices log in with the same set of credentials.
Session-based authentication is a suitable choice if you don’t have a large number of users or if you prefer to avoid the additional complexity of token-based systems. However, once you start building modern applications that require distributed systems, it becomes heavy fast.
Token based authentication (JWT)
A JWT (JSON Web Token) is a self-contained token that includes enclosed data and a signature. It has three parts (each part in base64 encoded).
-
Header: metadata about the token (The signing algorithm)
-
Payload: user information and claims (like
sub
,iat
,exp
) -
Signature: Cryptographic verification.
It looks like this
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
I could decode it in any client, inspect the data inside, and verify its integrity using a secret key. This was powerful.
But I quickly learned,
JWTs are just containers. They don’t enforce security; you do.
They solve a specific problem: how to send identity claims with each request in a stateless way. If you build services that scale horizontally, JWTs let you skip the need for a shared state.
But JWTs come with tradeoffs. They are not revocable by default, and you have to manage their lifecycle carefully.
Why you still need state in a stateless token system?
At one point, I was obsessed with building a “purely stateless” auth system. No sessions, no state. Just JWTs.
That dream ended when I realized that to log out of a user, you need to state.
When I first heard about JWTs, they were described as this magical thing that could simplify authentication and make APIs stateless. Naturally, I was intrigued. I imagined a world where I didn’t need session stores, didn’t need to worry about cross-server session syncing, and could just rely on this neat little token to handle everything. Spoiler: reality is much more complicated.
- Keep track of token versions per user(server state)
JWTs can’t be revoked unless you track them somewhere. Once they’re issued, they’re valid until they expire. If a token is stolen, you are stuck unless you:
- Store token IDs in a denylist(server state)
And then there are Refresh tokens. This leads us to the next section ….
Refresh tokens and token rotation
Initially, I set long expiry times on JWTs to avoid user inconvenience (Just like I did with sessions). However, I soon realized that was a security risk. If the token was leaked, it was valid for days. So, I shortened the access token lifespan and added refresh tokens.
How it works:
-
When the user logs in, they are presented with two tokens: the access token and the refresh token. (I will discuss the way these tokens are sent to the client side towards the end of the article. For now, assume that they are sent inside cookies.
The access token is short lived (usually 15 minutes) but the refresh token is long lived (usually counted in days;15 days)
-
For tasks that require user validation, it is done by sending the access token. However, if the access token has expired, a new access token is obtained with the help of the refresh token. In summary, the refresh token is used solely to obtain new access tokens, while the access tokens are utilized in scenarios where user validation is required.
This setup reduces the impact of leaked access tokens.
However, I then read about refresh token reuse attacks, where a stolen refresh token is used to generate multiple access tokens throughout the lifetime of the refresh token.
Solution: Token rotation. Each time a refresh token is used, it is invalided and replaced. Then, this new token is sent to the client. This prevents the previous refresh token from being used to generate new access tokens.
To implement the above behavior, I used the approach outlined below when creating the refresh token and the access token.
I add the claim jti
for both of them jti
is a claim that contains a ULID or UUID which helps to uniquely identify the token). Then i store the following information in Redis
In the above diagram ajti
and rjti
are the JTI of the access token and refresh token respectively. The aexp
and rexp
are the expiry times of the access token and refresh token respectively.
When a request to refresh the access token has arrived, the below steps happen,
- Verify the Refresh token JWT using the JWT decoding library and the secret key (If the encryption algorithm is symmetric, use the secret key. If it is asymmetric, use the public key).
- Then check if a key with the given JTI exists on Redis (It is better if you add a prefix refresh_ or access_ for the relevant token type before storing it in Redis because even if a collision occurs, it won’t matter).
- Then, create a new refresh token and an access token. Remove keys in Redis that represent the previous tokens and add the newly generated ones to Redis.
When there is an activity that needs validation, as discussed earlier, the access token is sent the above-sent access token is validated in the below-given ways:
- First, the access token is validated using the same JSON Web Token (JWT) library as the refresh token.
- Then, Redis is checked to see whether there is a key having the same JTI as the access token claims.
- If all those conditions are met, the request is considered valid.
Now, you might be wondering why the hell we are validating the access token against Redis. Wouldn’t it just increase the overhead?
I will give you a simple answer: Yes, it will.
But hear me out …
Suppose a situation where you have developed a very sensitive application, so a user obtains your newly rotated access token and refresh token. Now, he can continue to function as you. However, when you notice this and revoke the token (I will explain how this revocation works later), if the access token is completely stateless, then the user can continue to use your account until the access token expires.
For protection against such events, you can employ the above stateful approach, as I do, or use reauthentication wherever you need that added peace of mind. However, I prefer this design, particularly when using reauthentication for highly sensitive tasks, such as account deletion.
How does revoke functionality work?
There is a table called ‘sessions’ (note that this is distinct from the sessions I mentioned earlier; it is simply a name I assigned). Feel free to use any name you prefer. This table contains the following:
Column Name | Description |
---|---|
id | Unique identifier for the session (usually the refresh token JTI) |
ip_address | IP address of the user when the session was created |
device_information | Information about the device used for the session |
location_information | Location details of the user when the session was created (When Logged in) |
When the user logs in, I would fill this table with the refresh token details, and then, every time I rotate tokens, I would update this table in a non-user-blocking fashion.
This provided me with the ability to display all logged-in sessions to the user, allowing them to log out of any suspicious device. For this, I would reauthenticate the user again to ensure that this is not done using some stolen tokens.
Even though the above approach is no longer stateless, it solves all the problems that we had with the previous model, and more importantly, it is now secure.
How to send the tokens and where to store them?
At one point, I thought storing JWTs in localStorage
was fine. Then, I learned about XSS (Cross-Site scripting) and realized that any script injection vulnerability could leak my tokens.
Here is what I learned in the hard way:
Storage | Pros | Cons |
---|---|---|
localStorage | Easy to use, persistent across sessions | Vulnerable to XSS attacks, tokens can be stolen by malicious scripts |
sessionStorage | Similar to localStorage , but cleared on tab close | Still vulnerable to XSS attacks, tokens can be stolen by malicious scripts |
Cookies (HttpOnly) | Secure against XSS, can be set to SameSite | Requires careful configuration, vulnerable to CSRF (Cross-Site Resource Forgery) if not protected |
Store Access tokens in the memory
This is done by sending the access token in the response header stored as the value of a key named X-New-Access-Token
. This can be sent in many ways, but I prefer this method.
Then, this token is efficiently stored in the memory.
Store Refresh tokens as HttpOnly, Secure cookies
Refresh tokens are stored as HttpOnly secure cookies because JavaScript cannot access them. But still, they need to be protected from CSRF attacks
How to protect from CSRF attacks?
I take a generally simple approach here to mitigate CSRF attacks. When issuing the refresh token, I store the IP address of the request in Redis.
It would look something like this:
refresh_${jti}_ip: the_ip_address_of_the_request
Here the jti is the JTI of the refresh token and refresh_ is the prefix that I talked about earlier.
Then, when revalidating the refresh token (or more simply upon token rotation), I would check the new request IP address against the one in Redis. If they are not equal, I would calculate the distance between the 2 IP addresses using some API. Then, according to the distance difference (D, measured in Kilometers), I would give it a score (S, given out of 100). For example, consider below:
- D ≥ 100 → S < 20
- 50 ≤ D < 100 → 20 ≤ S ≤ 50
- 0 ≤ D < 50 → 50 < S ≤ 100
Based on the above score, I consider whether to reauthenticate the user, log the user, or proceed with the request.
After successfully validating the IP address, the new (rotated) refresh token is saved in Redis along with the new IP address of the request.
On a side note, you should always use good options on your cookies, such as:
- HttpOnly
- Secure
- SameSite=Strict or Lax
To ensure that you are adequately protected from CSRF attacks.
Why you still need to understand this even if you use Auth0
Tools like Auth0, Firebase Auth, and Clerk can save you a ton of time. But they also abstract away the mechanics of how auth works. Using them blindly means:
- You may misuse tokens or store them in an unsafe manner.
- You won’t know how to debug issues when something goes wrong.
- You can’t confidently build secure, custom logic around them.
Understanding authentication makes you a better engineer, even if you delegate it.
Final thoughts
Authentication is more than just letting people in. It’s the gateway to everything else: access control, permissions, personalization. If you get the auth wrong, nothing else matters.
Learn it. Understand the tradeoffs. Then, you can choose the right tools with confidence.