Tokens, Authentication, Authorization & Roles
How a system knows who is calling (authentication), what they may do (authorization), how tokens carry identity, and how roles and permissions are modelled. Examples from net banking, an e-commerce store and a payment gateway in Laravel, Spring Boot, React and MySQL.
Running examples: a bank customer transferring money, a shopper placing an order, a merchant calling a payment API
1. Authentication vs authorization
Authentication answers "who are you?". The caller proves identity with something they know (password, PIN), something they have (OTP on a phone, hardware key) or something they are (fingerprint). Once proven, the server issues a session or a token so the caller does not send the password on every request.
Authorization answers "what are you allowed to do?". It runs after authentication, on every request, and decides whether this identity may perform this action on this record.
The two fail differently. A failed authentication returns 401 Unauthorized (log in again). A failed authorization returns 403 Forbidden (you are known but not allowed), or 404 when you do not want to reveal that the record exists.
| Step | Net banking | E-commerce | Payment gateway API |
|---|---|---|---|
| Authentication | Customer ID + password + OTP | Email + password, or Google login | Merchant API key / OAuth client credentials |
| Proof carried afterwards | Session cookie (web) or access token (mobile app) | Session cookie | Bearer token or HMAC-signed request |
| Authorization question | Is account 1234 owned by this customer and is the amount within the daily limit? | Is order 187 placed by this shopper? | Does this key have the refunds:write scope and belong to the merchant that owns payment pay_9X? |
| Failure response | 403 + "Limit exceeded" | 404 (hide other people's orders) | 403 + error code INSUFFICIENT_SCOPE |
Every protected request
- Read credential (cookie / Authorization header)
- Authenticate: resolve the user or client
- Authorize: role, permission, ownership, limits
- Run business logic
- Audit-log sensitive actions
2. What a token is and the main token types
A token is a string that represents a proven identity for a limited time. The client stores it and sends it with each request, usually as "Authorization: Bearer <token>". The server checks it instead of asking for the password again.
There are two families. Opaque tokens are random strings; the server looks them up in a database or cache to find the user (Laravel Sanctum personal access tokens, OAuth tokens stored by Passport). Self-contained tokens, mainly JWT (JSON Web Token), carry the user id, roles and expiry inside the token itself, signed by the server, so any service holding the key can verify them without a database lookup.
| Type | How it is verified | Revocation | Typical use |
|---|---|---|---|
| Session id cookie | Lookup in session store | Delete the session | Server-rendered web apps, Laravel Blade, admin panels |
| Opaque token (Sanctum) | Hash lookup in personal_access_tokens | Delete the row, instant | Mobile apps and SPAs talking to one Laravel API |
| JWT access token | Verify signature + expiry, no DB | Hard: valid until it expires, so keep it short (5–15 min) | Microservices: API gateway issues, many services verify |
| Refresh token | DB lookup, rotated on each use | Delete or mark used | Getting a new short access token without re-login |
| API key | Hash lookup | Rotate the key | Server-to-server: merchant calling a payment gateway |
Anatomy of a JWT (header.payload.signature, each part base64url-encoded)
// header
{ "alg": "RS256", "typ": "JWT", "kid": "2026-key-1" }
// payload (claims) - readable by anyone, so never put secrets here
{ "sub": "customer-42", "roles": ["CUSTOMER"], "scope": "accounts:read transfers:write",
"iat": 1791700000, "exp": 1791700900, "iss": "https://auth.bank.example" }
// signature = RSA-SHA256(header + "." + payload, private key of the auth server)
The payload is only encoded, not encrypted. Anyone can decode it, but nobody can change it without breaking the signature. That is why a JWT may contain a user id and roles but never a password, card number or Aadhaar number.
3. Laravel: login, tokens and protecting routes
Sanctum token login for a mobile banking app
// routes/api.php
Route::post('/login', LoginController::class)->middleware('throttle:5,1');
Route::middleware('auth:sanctum')->group(function () {
Route::get('/accounts', [AccountController::class, 'index']);
Route::post('/transfers', [TransferController::class, 'store'])->middleware(['ability:transfers:write', 'otp.verified']);
});
// LoginController
public function __invoke(Request $request)
{
$data = $request->validate(['customer_id' => 'required', 'password' => 'required', 'device' => 'required|max:100']);
$user = User::where('customer_id', $data['customer_id'])->first();
if (! $user || ! Hash::check($data['password'], $user->password)) {
throw ValidationException::withMessages(['customer_id' => 'Invalid credentials.']); // same message for both cases
}
$token = $user->createToken($data['device'], ['accounts:read', 'transfers:write'], now()->addMinutes(30));
return ['token' => $token->plainTextToken, 'expires_in' => 1800];
}
Sanctum stores only a SHA-256 hash of the token in personal_access_tokens, so a leaked database does not expose working tokens. Abilities (scopes) limit what a token can do: a read-only token issued to a budgeting app cannot move money even though it belongs to the same customer.
Web pages in the same Laravel app do not need tokens at all: the auth middleware uses the session cookie. Use tokens only for clients that are not your own Blade pages: mobile apps, SPAs on another domain, partners.
Ownership check with a policy (authorization)
class AccountPolicy
{
public function view(User $user, Account $account): bool
{
return $account->customer_id === $user->customer_id;
}
}
public function show(Account $account)
{
$this->authorize('view', $account); // 403 if it is someone else's account
return new AccountResource($account->load('recentTransactions'));
}
4. Spring Boot: Spring Security with JWT
Resource server that validates JWTs and checks roles
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // stateless API, no cookies
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/api/login").permitAll()
.requestMatchers(HttpMethod.POST, "/api/loans/*/approve").hasRole("BRANCH_MANAGER")
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults())) // verifies signature + expiry
.build();
}
}
@RestController
@RequestMapping("/api/accounts")
class AccountController {
@GetMapping("/{id}")
@PreAuthorize("@accountGuard.owns(authentication, #id)") // ownership, not just role
public AccountDto get(@PathVariable Long id) { return service.find(id); }
}
Spring Security is a chain of servlet filters. The JWT filter authenticates and stores an Authentication object in the SecurityContext; @PreAuthorize then authorizes at method level. The idea is the same as Laravel's auth middleware followed by a policy.
5. React: storing and sending tokens safely
API client with an in-memory access token and refresh on 401
let accessToken = null; // kept in memory, not localStorage
export async function api(path, options = {}) {
const send = () => fetch(`/api${path}`, {
...options,
credentials: 'include', // sends the HttpOnly refresh cookie
headers: { 'Content-Type': 'application/json', ...(accessToken && { Authorization: `Bearer ${accessToken}` }) },
});
let res = await send();
if (res.status === 401) { // access token expired: rotate once, then retry
const r = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' });
if (!r.ok) { window.location.assign('/login'); return; }
accessToken = (await r.json()).access_token;
res = await send();
}
return res;
}
// Route guard: UX only, the server still authorizes every call
function RequireRole({ role, children }) {
const { user } = useAuth();
return user?.roles.includes(role) ? children : <Navigate to="/forbidden" />;
}
Anything in localStorage can be read by any script on the page, so one XSS bug leaks the token. Keeping the short-lived access token in memory and the refresh token in an HttpOnly, Secure, SameSite cookie limits that damage. Hiding a button in React is never security; the API must check again.
6. Roles and permissions (RBAC) with the MySQL design
Role-Based Access Control groups permissions into roles and assigns roles to users. Code checks permissions ("loans.approve"), not role names, so a new role such as "Deputy Manager" needs no code change, only rows in the database.
Real systems add conditions roles cannot express: a branch manager may approve loans only for their own branch and only up to Rs 25 lakh. Those rules live in policies (attribute-based checks) on top of RBAC.
roles / permissions / role_user / permission_role
| Column | Type / constraint |
|---|---|
| roles.id, roles.name | CUSTOMER, TELLER, BRANCH_MANAGER, AUDITOR |
| permissions.id, permissions.name | accounts.view, transfers.create, loans.approve, reports.export |
| permission_role(role_id, permission_id) | many-to-many, composite PK |
| role_user(user_id, role_id, branch_id NULL) | role scoped to a branch when needed |
Packages such as spatie/laravel-permission create exactly these tables.
Checking permissions plus a limit
// LoanPolicy
public function approve(User $user, Loan $loan): Response
{
if (! $user->hasPermissionTo('loans.approve')) return Response::deny('Not allowed to approve loans.');
if ($loan->branch_id !== $user->branch_id) return Response::denyAsNotFound();
if ($loan->amount > $user->approval_limit) return Response::deny('Above your approval limit; escalate to regional office.');
return Response::allow();
}
| System | Roles | Rule that roles alone cannot express |
|---|---|---|
| Banking | Customer, Teller, Branch Manager, Auditor (read-only) | Maker-checker: the person who creates a payment cannot approve it |
| E-commerce | Shopper, Seller, Support agent, Admin | A seller edits only their own products |
| Payment gateway | Merchant owner, Developer, Finance, Read-only | Refunds above Rs 50,000 need the owner role and 2FA |
7. Interview questions
Session or JWT for a banking web app?
Sessions (or opaque tokens): they can be revoked instantly on logout, password change or fraud alerts. JWTs suit distributed services where a central lookup is expensive, but they need very short expiry plus refresh-token rotation.
How do you log out a JWT user?
Delete the refresh token so no new access token can be issued, and keep access tokens short. For immediate revocation keep a deny-list of token ids (jti) in Redis until they expire.
What is IDOR?
Insecure Direct Object Reference: an authenticated user changes /orders/187 to /orders/188 and sees another person's order. Fix it with ownership checks (policies or scoped queries) on every record.
Where should the role check happen in a React + Laravel app?
In Laravel (middleware, policies). React checks only decide what to show.
