All lessons
BackendIntermediate14 min readIn my stack

Authentication & authorization

Proving who a user is, then deciding what they are allowed to do.

  • Sessions
  • JWT
  • OAuth2
  • OTP
  • Roles & permissions
  • 2FA
Step 1/6
You click “Sign in with ID” on app.com. The app never wants to see your password — that's the whole point of OAuth.

In 30 seconds

Authentication answers 'who are you?' and authorization answers 'what may you do?'. Users prove identity with passwords, one-time codes, social login or keys, and the server remembers them with a session or a token. Roles and permissions then decide access. Most security incidents start with small mistakes here, so it pays to know the patterns well.

Why I use it

  • I build auth for every product: customer login on web and mobile, staff login for admin panels, and machine access for partners.
  • I use SMS OTP, social login and 2FA because each audience needs a different balance of convenience and security.
  • I design roles and permissions carefully because in a multi-tenant SaaS a single missing check can expose another company's data.
  • I treat auth as its own module with tests, because it is the part attackers try first.

How it works

01Sessions vs tokens

After login the server must recognise you on the next request. With sessions, the server stores your login state (in Redis or a database) and gives the browser a random session id in a cookie, like a cloakroom ticket. The ticket means nothing on its own; the server looks it up. Logging out is easy: delete the session. With tokens, the server gives the client a signed token that itself contains claims such as user id and expiry, and the server verifies the signature instead of looking anything up. Sessions are the best default for websites on your own domain. Tokens suit mobile apps, third-party clients and service-to-service calls. Many products use both.

  • Session cookie: HttpOnly, Secure, SameSite=Lax (or Strict), short idle timeout.
  • Regenerate the session id at login to prevent session fixation.
  • Cookies need CSRF protection; bearer tokens in headers need XSS protection.

02JWT: access tokens, refresh tokens and revocation

A JWT (JSON Web Token) has three parts: header, payload with claims, and signature. Anyone can read the payload, so never put secrets in it; the signature only proves it was not changed. Use a short-lived access token (5 to 15 minutes) for API calls and a long-lived refresh token, stored securely, to get new access tokens. Rotate refresh tokens: each use issues a new one and invalidates the old. If an old refresh token is ever used again, assume theft and revoke the whole token family. Because JWTs are valid until they expire, revocation needs extra work: a denylist of token ids (jti) in Redis, a token version on the user, or re-checking the user on each request. Prefer asymmetric signing (RS256 or EdDSA) when several services verify tokens, and always check alg, iss, aud and exp.

ts
// Verify an access token (jose library)
import { jwtVerify, createRemoteJWKSet } from 'jose';
 
const jwks = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));
 
export async function verifyAccessToken(token: string) {
  const { payload } = await jwtVerify(token, jwks, {
    issuer: 'https://auth.example.com',
    audience: 'api.example.com',
    algorithms: ['RS256'],
  });
  if (await denylist.has(payload.jti as string)) throw new Error('Token revoked');
  return payload;
}

03OAuth2, OpenID Connect and social login

OAuth2 is a standard for delegated access: a user lets an app act on their behalf without giving it their password. OpenID Connect (OIDC) is a layer on top that adds identity, an ID token that says who the user is. 'Sign in with Google' or 'Sign in with Apple' is OIDC. For web and mobile apps use the Authorization Code flow with PKCE (Proof Key for Code Exchange), which protects the code from interception; the old Implicit flow is deprecated. Always validate the state parameter against CSRF, verify the ID token signature, issuer, audience and nonce, and link social accounts by the provider's stable subject id, not by email alone. iOS apps that offer third-party social login must also offer a privacy-focused option such as Sign in with Apple (App Store guideline 4.8).

04Passwords, OTP and 2FA

Never store passwords, store hashes. Use Argon2id (OWASP minimum: 19 MiB memory, 2 iterations, parallelism 1) or bcrypt with cost 10 or more; note bcrypt only uses the first 72 bytes. Frameworks do this for you: Laravel's Hash facade, or the argon2 package in Node. Check new passwords against known breached lists and rate limit login attempts. One-time passwords by SMS or email are convenient for phone-first markets: generate a random 6-digit code, store only its hash with a short expiry (about 5 minutes), allow a few attempts, and rate limit sends per phone and per IP to stop SMS cost abuse. Two-factor authentication adds a second proof: TOTP apps, passkeys (WebAuthn) or SMS as a weaker fallback. Require 2FA for staff and admins.

php
// Laravel: OTP verification with rate limiting
$key = 'otp:'.$request->phone;
if (RateLimiter::tooManyAttempts($key, 5)) {
    abort(429, 'Too many attempts, try again later.');
}
RateLimiter::hit($key, 600);
 
$otp = OtpCode::where('phone', $request->phone)
    ->where('expires_at', '>', now())
    ->latest()->first();
 
if (! $otp || ! Hash::check($request->code, $otp->code_hash)) {
    throw ValidationException::withMessages(['code' => 'Invalid or expired code.']);
}
$otp->delete();
RateLimiter::clear($key);

05Roles, permissions and API keys

RBAC, role-based access control, gives users roles (owner, manager, cashier) and roles carry permissions (orders.refund, menu.edit). Check permissions, not role names, in code: 'can refund' survives new roles, 'is manager' does not. Keep checks on the server in one place, such as Laravel policies and gates or NestJS guards, and hide buttons in the UI only as a convenience. In multi-tenant apps every check also includes the tenant: a manager of company A has no rights in company B. For machine clients, issue API keys with scopes: generate a long random key, show it once, store only its hash, add a visible prefix to identify it (for example live_ or test_), and support expiry, rotation and per-key rate limits. Log who used which key.

06Secure defaults checklist

Most auth bugs come from details. Set cookies with HttpOnly so JavaScript cannot read them, Secure so they only travel over HTTPS, SameSite=Lax or Strict against CSRF, and a narrow Path. Use the __Host- cookie prefix for session cookies when possible. Return the same error for 'unknown user' and 'wrong password' to avoid account enumeration. Rate limit login, OTP and password reset. Make reset links single-use and short-lived. Log security events (logins, failed attempts, role changes, impersonation) and alert on spikes. For support impersonation, make it time-boxed, require a reason and audit it.

http
Set-Cookie: __Host-session=9f8c...; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=7200

Use it when

  • Sessions with secure cookies for your own website and admin panel on one domain.
  • Short-lived JWT access tokens plus rotating refresh tokens for mobile apps and multiple services.
  • OIDC social login to reduce sign-up friction; SMS OTP where users are phone-first.
  • Scoped API keys for partners and server-to-server integrations.

Skip it when

  • Do not use JWTs as a session replacement for a simple same-domain website just because it is trendy.
  • Do not store tokens in localStorage when an HttpOnly cookie would work.
  • Do not build your own crypto, hashing or OAuth server when a maintained library or provider exists.

Alternatives

  • Laravel Sanctum / Passport

    Built-in session and token auth, or a full OAuth2 server, for Laravel apps.

  • Auth providers (Auth0, Clerk, Cognito, Keycloak)

    Hosted or self-hosted identity; faster start, less control, ongoing cost.

  • Passkeys (WebAuthn)

    Phishing-resistant passwordless login; increasingly the best default for 2FA.

  • Better Auth / Auth.js

    Open-source auth libraries for TypeScript and Next.js projects.

Best practices

  1. Hash passwords with Argon2id or bcrypt through a maintained library.
  2. Short-lived access tokens, rotating refresh tokens, and a revocation strategy.
  3. Authorization Code with PKCE for OAuth; validate state, nonce and token claims.
  4. Rate limit login, OTP sends and verification, and password resets.
  5. Check permissions on the server, always scoped to the tenant.
  6. Store only hashes of OTP codes, API keys and reset tokens.
  7. Require 2FA for staff and admins; log and alert on security events.

Common mistakes

  • Long-lived JWTs with no way to revoke them after a stolen phone or a fired employee.
  • Trusting a user id or role sent from the client instead of the authenticated context.
  • Unlimited OTP sends, which turns your SMS gateway into someone else's free attack tool.
  • Accepting the 'none' algorithm or not checking audience and issuer on JWTs.
  • Hiding admin buttons in the UI but leaving the API endpoints unprotected.

Deep dive

07Where tokens live: browsers, the BFF pattern and mobile storage

Any token JavaScript can read, an XSS bug can steal. That is why storing access and refresh tokens in localStorage is risky for a single-page app. The Backend-for-Frontend (BFF) pattern removes tokens from the browser entirely: a thin server on your domain runs the OAuth flow, keeps tokens server-side, and gives the browser only an HttpOnly, Secure, SameSite session cookie. API calls go through the BFF, which attaches the access token. On mobile there is no cookie jar to lean on, so store refresh tokens in the platform's secure storage, Keychain on iOS and Keystore-backed storage on Android, never in plain preferences. The OAuth 2.0 Security Best Current Practice (RFC 9700, January 2025) also recommends sender-constrained tokens where possible, such as DPoP (RFC 9449), which binds a token to a key the client holds, so a stolen token alone is useless.

  • SPA on your own domain: session cookie or BFF, not localStorage.
  • Mobile: secure storage plus refresh token rotation, bound to the device.
  • Service to service: client credentials or mTLS, short-lived tokens.

08Passkeys and WebAuthn under the hood

A passkey is a key pair created by the user's device for one website. The private key never leaves the device or the user's password manager; the server stores only the public key. To sign in, the server sends a random challenge, the device asks for a fingerprint, face or PIN, and signs the challenge. The server verifies the signature with the stored public key. Crucially, the browser includes the origin in what is signed, so a fake site on a look-alike domain cannot get a valid signature: passkeys are phishing-resistant in a way that SMS codes and TOTP are not. Your site is the relying party, identified by an RP ID such as example.com. Use a maintained library on both sides, keep at least one recovery path, and let users register several passkeys.

ts
// Browser, using @simplewebauthn/browser
import { startAuthentication } from '@simplewebauthn/browser';
 
const optionsJSON = await api.post('/webauthn/login/options'); // fresh, single-use challenge
const assertion = await startAuthentication({ optionsJSON });
await api.post('/webauthn/login/verify', assertion);
 
// Server (@simplewebauthn/server verifyAuthenticationResponse) must check:
// expected challenge, expected origin and RP ID, the signature against the
// stored public key, and user verification if your policy requires it.

09Timing attacks, enumeration and credential stuffing

Attackers learn from tiny differences. If 'unknown email' returns in 5 ms and 'wrong password' in 80 ms because only the second runs a hash, they can list which accounts exist. Run a dummy hash for unknown users and return the same message. Compare secrets such as API keys, reset tokens and webhook signatures with constant-time functions, hash_equals in PHP or crypto.timingSafeEqual in Node, never with ===. Credential stuffing, trying leaked email and password pairs from other sites, is the most common attack on login forms. Defend in layers: rate limits per account and per IP, a check against breached passwords (the Have I Been Pwned range API uses k-anonymity, so you send only the first five characters of a SHA-1 hash), CAPTCHA after suspicious patterns, and alerts on login failure spikes. Hard account lockouts can be abused to lock real users out, so prefer progressive delays.

ts
import { createHmac, timingSafeEqual } from 'node:crypto';
 
export function isValidSignature(rawBody: Buffer, headerHex: string, secret: string) {
  const expected = createHmac('sha256', secret).update(rawBody).digest();
  const received = Buffer.from(headerHex, 'hex');
  // timingSafeEqual throws on different lengths, so check that first
  return received.length === expected.length && timingSafeEqual(received, expected);
}

10Authorization beyond roles: ABAC, ReBAC and list filtering

Roles answer 'what kind of user is this?', but real rules often depend on more. Attribute-based access control (ABAC) adds conditions: a manager may refund only orders from their own branch and only up to a limit. Relationship-based access control (ReBAC), popularised by Google's Zanzibar paper and implemented by OpenFGA and SpiceDB, models permissions as a graph: a user can edit a document because they belong to a team that owns its folder. Policy engines such as OPA or Cedar keep rules outside the code. The hardest part is lists: checking each row after loading it breaks pagination and leaks counts. Push authorization into the query instead, as a scope that adds tenant, branch or ownership conditions, so the database returns only rows the user may see. Test both directions: allowed users see data, everyone else gets 404.

Real-world scenarios

01

A manager's phone is stolen

Problem

A branch manager reports their phone stolen at 11 pm. The phone had the admin app signed in, with permission to refund orders and change prices.

How to solve it

Act in minutes, not hours. Revoke all refresh token families and server sessions for that user, and bump a token version on the user record so access tokens that are still valid fail the per-request check. Kill any active socket connections for the user. Rotate any API keys or device tokens issued to that phone. Then check the audit log for refunds, price changes or exports since the time of theft and reverse what is needed. Afterwards, require 2FA or a passkey for staff, keep access tokens short, and add a 'sign out all devices' button the support team can use without a developer.

02

Thousands of failed logins from many IPs

Problem

Overnight the login endpoint received 400,000 attempts from thousands of IPs, each trying a different email with a plausible password. A few accounts were taken over.

How to solve it

This is credential stuffing with leaked passwords. Immediately add rate limits per account, not only per IP, and put a CAPTCHA or proof-of-work challenge in front of login when failure rates spike; a WAF or bot protection at the edge helps with volume. Force password resets for compromised accounts and invalidate their sessions. Make the response identical for unknown users and wrong passwords. Then fix the root cause: check passwords against breached lists at sign-up and login, offer passkeys or 2FA, and alert on unusual failure ratios so the next wave is caught within minutes.

03

An enterprise client wants single sign-on

Problem

A large customer of the SaaS wants their staff to log in with the company identity provider, and wants accounts removed automatically when employees leave.

How to solve it

Support OIDC, and SAML if the client requires it, configured per tenant: issuer, client id, secret or metadata. Verify the client owns its email domain, then route users with that domain to the tenant's identity provider. Create users just in time on first login, mapping groups to your roles, and link accounts by the provider's subject id rather than by email. Allow the tenant to enforce SSO so passwords are disabled for their staff, but keep an emergency owner login. For automatic removal, implement SCIM provisioning or at least periodic checks, and shorten sessions so deprovisioned users lose access quickly.

Cheat sheet

Laravel: new session id after login
$request->session()->regenerate();
Laravel: upgrade old hashes on login
if (Hash::needsRehash($user->password)) { $user->update(['password' => Hash::make($plain)]); }
Sanctum token with abilities
$user->createToken('pos-terminal', ['orders:create'])->plainTextToken
Sanctum: check an ability
$request->user()->tokenCan('orders:create')
Secure random token (PHP)
bin2hex(random_bytes(32))
Secure random token (Node)
crypto.randomBytes(32).toString('base64url')
Constant-time compare (PHP)
hash_equals($expected, $given)
Read JWT claims without verifying (debug only)
import { decodeJwt } from 'jose'; decodeJwt(token)
OIDC discovery document
https://auth.example.com/.well-known/openid-configuration

In my real projects

  • In a multi-tenant POS SaaS: RS256 JWTs re-validated per request with a denylist, 2FA for staff and operators, device tokens for tills, guest SMS OTP, and scoped live/test API keys with rate limits and rotation.
  • The same SaaS supports time-boxed staff impersonation that requires a reason, plus platform, company and custom roles built from permission keys.
  • On a restaurant-chain platform: email and password, registration with SMS OTP, Google and Apple sign-in on web and native, admin OTP login and 12 admin roles.
  • When migrating a WordPress shop to Laravel, I kept WordPress password-hash compatibility so customers did not have to reset passwords.

Questions & answers

Q1Sessions or JWT: which do you choose and why?

For a website and admin on my own domain, server sessions with a secure HttpOnly cookie. They are simple, revocable instantly and well supported by frameworks. JWTs make sense when many services must verify identity without a shared session store, or for mobile and third-party clients. Even then I keep access tokens short-lived and pair them with rotating refresh tokens and a revocation path. The common mistake is choosing JWT for a single same-domain web app, then rebuilding sessions badly with a denylist. The real question is who the clients are and how revocation will work.

Q2How do refresh token rotation and reuse detection work?

The client holds a short-lived access token and a long-lived refresh token. When the access token expires, the client sends the refresh token and receives a new access token and a new refresh token; the old refresh token is marked used. All tokens from one login share a family id. If a used refresh token is presented again, either the legitimate client or an attacker has a stolen copy, and you cannot tell which, so you revoke the entire family and force a new login. Store refresh tokens hashed on the server, bind them to a device, and expire inactive ones.

Q3What is the difference between OAuth2 and OpenID Connect?

OAuth2 is about authorization: it lets a client obtain an access token to call an API on a user's behalf, without the user's password. It does not define who the user is. OpenID Connect is an identity layer on top of OAuth2. It adds the ID token, a signed JWT with claims like the subject id, email and authentication time, plus a userinfo endpoint and discovery. So 'Sign in with Google' is OIDC, while 'let this app read my calendar' is OAuth2. For both, use the Authorization Code flow with PKCE, and validate state, nonce, issuer and audience.

Q4How do you store passwords securely?

Hash them with a slow, salted, memory-hard algorithm designed for passwords: Argon2id is the first choice, with at least OWASP's minimum of 19 MiB memory, two iterations and parallelism of one. bcrypt with a cost of 10 or more is acceptable, keeping its 72-byte input limit in mind. Never use plain SHA-256 or MD5, and never encrypt passwords reversibly. Use the framework's hashing API, which handles salts and lets you rehash when parameters change. Add rate limiting and breached-password checks, and use generic error messages so attackers cannot discover which accounts exist.

Q5How would you implement roles and permissions in a multi-tenant app?

Users have memberships per tenant, and each membership has one or more roles. Roles are sets of permission keys like orders.refund or reports.view; built-in roles exist and tenants can create custom ones. Code checks permissions, never role names, through one central mechanism such as policies or a guard. Every check also verifies the resource belongs to the current tenant, and cross-tenant access returns 404. Platform-level roles for my own operators are separate from tenant roles, and sensitive actions like impersonation require a reason and are audited. Tests include a wrong-tenant case for each module.

Q6How do you protect an SMS OTP login from abuse?

Two threats: guessing codes and burning money on SMS. For guessing, use a random 6-digit code, store only its hash, expire it after about five minutes, allow around five attempts, and invalidate it after use. For SMS cost abuse, rate limit sends per phone number, per IP and per device, add cooldowns between resends, block premium-rate and unexpected country prefixes, and consider a CAPTCHA after repeated requests. Monitor send volume and alert on spikes. Retry SMS only on clear connection failures, so a slow provider does not cause duplicate messages to the same user.

Senior-level questions

S1Design authentication for one product with a website, mobile apps and a partner API.

One identity core, three entry styles. The website and admin use server sessions in HttpOnly SameSite cookies, with CSRF protection and 2FA or passkeys for staff. Mobile apps use the Authorization Code flow with PKCE, or a first-party login endpoint, receiving a short-lived access token and a rotating refresh token kept in secure storage and bound to a device record, so support can revoke one device. Partners get either OAuth client credentials or scoped API keys stored hashed, with rotation and per-key rate limits. All three resolve to the same user or client model and the same permission checks, scoped by tenant. Trade-off: running three mechanisms costs more than one, but forcing a single token model on all of them usually weakens the website or complicates the partners.

S2Where should a single-page app store its tokens?

Ideally nowhere JavaScript can reach. localStorage and sessionStorage are readable by any script on the page, so one XSS bug or compromised dependency leaks long-lived credentials. In-memory storage is better but lost on reload, so you still need a refresh mechanism. My preference is a session cookie when the SPA and API share a site, or a Backend-for-Frontend that runs OAuth server-side and gives the browser only an HttpOnly, Secure, SameSite cookie. Cookies bring CSRF risk, which SameSite plus CSRF tokens or checking Origin handle well. If tokens must reach the browser, keep access tokens in memory for minutes, rotate refresh tokens with reuse detection, and consider DPoP. The trade-off is an extra server hop for the BFF in exchange for a much smaller blast radius from XSS.

S3How do you make authorization efficient on list and search endpoints?

Checking each row after loading breaks pagination, wastes queries and can leak information through totals. So I translate permissions into query filters. Every tenant-owned query gets a mandatory tenant condition, and role or attribute rules become scopes: branch managers see orders where branch_id is in their branches, cashiers only their own shifts. Policies still guard single-object actions like refund or delete. For search engines, I index the access attributes, tenant, branch and visibility, with each document and always filter on them. With relationship-based models, systems like OpenFGA can list the object ids a user can access, but for large result sets you need a filter you can push into the database. The trade-off is duplicating rule logic between policies and scopes, so both need tests.

S4How would you add SSO for enterprise tenants to a multi-tenant SaaS?

Treat identity providers as per-tenant configuration. Each tenant can register an OIDC provider, or SAML where enterprises insist, with verified email domains. The login page asks for the email first and routes to the tenant's provider when the domain matches. On the callback I validate the ID token or assertion fully, then find the user by provider and subject id, creating them just in time and mapping identity provider groups to tenant roles. Tenants can enforce SSO, disabling password login for their users, while a break-glass owner account remains. For offboarding, SCIM lets the provider deactivate users immediately; without it, short sessions limit exposure. The main trade-offs are support load, since misconfigured providers become your tickets, and the testing matrix across providers.

Glossary

Credential stuffing
Automated login attempts using email and password pairs leaked from other websites.
Account enumeration
Discovering which accounts exist from different error messages or response times.
PKCE
Proof Key for Code Exchange: a one-time secret that stops a stolen OAuth authorization code from being redeemed by someone else.
DPoP
Demonstrating Proof of Possession: binds a token to a client-held key, so a leaked token alone cannot be used.
BFF
Backend-for-Frontend: a small server that holds OAuth tokens for a web app and gives the browser only a session cookie.
Passkey
A phishing-resistant login credential made of a device-held private key and a server-stored public key.
Relying party
The website or app that accepts a login, such as an OIDC client or the site verifying a passkey.
ReBAC
Relationship-based access control: permissions derived from links like user, team, folder and document.
Session fixation
An attack where the victim is made to use a session id the attacker already knows; prevented by regenerating ids at login.
SCIM
A standard API that lets a company's identity provider create, update and remove user accounts in your app automatically.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.