The previous lesson closed with twelve prioritised measures for Nimbus, and number one was MFA. That was no accident: several of the others also revolve around the same question — who you are and what you can do. When half the headcount works outside the office, the data is in the cloud, a consultancy has remote access and the API is consumed by a mobile app, the perimeter stops being a line on the network. What is left between an attacker and the clinics' diaries is a credential and an authorisation decision. This lesson develops both: the life cycle of an identity and orphaned accounts, what the current guidance says about passwords, the forms of MFA ordered by their real phishing resistance, SSO and the identity protocols with the exact difference between OAuth 2.0 and OpenID Connect, sessions and JWTs with their correct validation as against the naive one, the five authorisation models with Nimbus's concrete design, and privileged accounts with just-in-time access and recertification.
Contents
- Identity as the new perimeter
- The identity life cycle and orphaned accounts
- Authentication factors and their weaknesses
- Passwords: what the guidance says today
- MFA ordered by phishing resistance
- SSO, federation and identity protocols
- Sessions and tokens: cookies and JWTs
- Authorisation models and Nimbus's RBAC
- Multi-tenancy and isolation
- Privileged, service and third-party accounts
- Recertification: the periodic access review
- Identity as the new perimeter
The classic model divided the world into inside and outside, and put the control on the boundary. That model describes Nimbus's reality less and less well:
| Element | Where it is | Does a network perimeter cover it? |
|---|---|---|
| Database and buckets (A-01, A-02, A-03) | The provider's cloud (A-05) | No |
| 19 remote employees | Homes, cafés, trains | No |
| Customers' mobile app | Thousands of other people's devices | No |
| Repository and CI/CD (A-06) | External SaaS | No |
| External consultancy (A-19) | Its own network | No |
| Mail, payment gateway, transactional e-mail | External SaaS | No |
The only thing that crosses all of those contexts is identity. If an attacker obtains Lucía's credentials, they do not need to get through any firewall: they walk in through the front door of every one of those systems, from anywhere in the world, and everything they do will look legitimate.
Hence the formulation that sums up the Zero Trust approach of 01-03 applied to this domain:
Identity is the new perimeter. A request is not trusted because it comes "from inside"; it is trusted because you can verify who is making it, with what device, for which resource and under what conditions, on every request.
Three concepts that must be separated precisely, because they are constantly confused:
| Concept | Question | Example at Nimbus |
|---|---|---|
| Identification | Who do you say you are? | Typing in lucia@nimbusreservas.example |
| Authentication | Can you prove it? | Password + FIDO2 key |
| Authorisation | What can you do? | Lucía can deploy; she cannot see payroll |
And the recurring trap: most serious incidents are not authentication failures, they are authorisation failures. The IDOR of 01-01 happened with a perfectly valid session. Authenticating well and authorising badly is the number one pattern in the OWASP Top 10.
- The identity life cycle and orphaned accounts
An identity is not a static record: it is a process with three moments, and it is the third that almost always fails.
flowchart LR
A["JOINING\nWho comes in, with what\naccess and who approves it"] --> B["CHANGES\nRole change, project,\ntemporary permissions"]
B --> C["LEAVING\nComplete revocation\nacross every system"]
B -->|"if nobody withdraws\nthe previous access"| D["PRIVILEGE\nACCUMULATION"]
C -->|"if some system\nis forgotten"| E["ORPHANED\nACCOUNT"]
D --> F["A user with more\npermissions than any\nadministrator"]
E --> G["Valid access with no\nowner and no oversight"]
2.1 Joining
The four requirements of a correct joining process:
- Based on the role, not on a copy. The classic mistake is "give them the same permissions as Iván". That also copies the permissions Iván accumulated on old projects, and privilege spreads like an infection. There must be a predefined access profile per role.
- With a recorded approval from the asset owner (the "owner" field of the 01-04 inventory exists for exactly this).
- With an expiry date if it is temporary. An intern, a three-month contract or a consultancy must have an end date in the system, not in somebody's memory.
- With a named identity. No shared accounts: without a named identity there is no traceability, and without traceability there is no non-repudiation (01-01).
2.2 Changes: privilege accumulation
This is the silent problem. Rubén joined support, helped out in billing for six months, took part in the data migration and now coordinates the team. If at each change permissions were added without withdrawing the previous ones, Rubén today has more access than anybody else at Nimbus, and nobody ever decided that.
The rule: every role change is a removal of the old role and a grant of the new one, not an addition. And every temporary permission is created with an end date.
2.3 Leaving: the Nimbus case
A developer leaves Nimbus. Lucía deactivates his mail account the same day and considers the process finished. Six months later, during the inventory review, this turns up:
| System | State after the "leaving" | Risk |
|---|---|---|
| Corporate e-mail | Deactivated | — |
| Cloud provider account (A-05) | Active, with read permissions on the buckets | Access to customer data from anywhere |
| GitHub | Active as an outside collaborator on the repository | Access to the code and the history, including old secrets |
| VPN | Deactivated | — |
| Database | Personal user dev_carlos active |
Direct access to production data |
| SSH key on the application server | Present in authorized_keys |
Access to the server without going through any central authentication |
| Ticketing tool (SaaS) | Active | Customer data in the tickets |
| Support WhatsApp group | Present | Screenshots containing customer data |
| Personal GitHub token he created | Active and with no expiry | Access to the code even if the account is deactivated |
Eight live accesses belonging to somebody who no longer works here. And none of them shows up on a firewall, because technically all of them are legitimate.
Why orphaned accounts are especially dangerous, beyond the obvious:
- Nobody watches them. Anomalous use of Lucía's account might get noticed; nobody misses
dev_carlos. - Nobody maintains them. Their password is not rotated, MFA is not added when it is rolled out, their permissions are not reviewed.
- They are the preferred target of credential stuffing. If that person reused the password and it appears in another service's breach, the door is still open.
- There is not always malicious intent. The ex-employee who keeps logging in "to help" creates an equally serious problem: access with no current authorisation and no control.
The correct leaving procedure for Nimbus:
LEAVER CHECKLIST - carried out on the LAST DAY, not afterwards
[ ] Deactivate the central identity (SSO) - blocks everything federated
[ ] Revoke active sessions and tokens (deactivating the account is not
enough: a live session keeps working)
[ ] Go through the list of NON-federated systems, one by one:
[ ] Cloud provider [ ] GitHub and personal tokens
[ ] Database [ ] SSH keys in authorized_keys
[ ] Ticketing SaaS [ ] Payment gateway [ ] Transactional e-mail
[ ] Remove from messaging groups and channels
[ ] Reassign ownership of their inventory assets (01-04)
[ ] Rotate the shared secrets they knew
[ ] Recover the laptop and the encryption recovery key
[ ] Record the leaving as completed, with date and responsible personThe two points most often forgotten are the revocation of sessions and tokens — deactivating an account does not invalidate an already-issued session, as you saw with pass-the-cookie in 02-03 — and the rotation of shared secrets: if that person knew the NAS password or the gateway key, those credentials are compromised by definition.
The structural measure that halves the problem: the more systems are federated with a single identity provider, the fewer boxes there are to tick. That is the main reason SSO is a security measure and not merely a convenience.
- Authentication factors and their weaknesses
A factor is a category of proof. Multi-factor authentication requires factors of different categories; two passwords are not two factors.
| Factor | What it is | Examples | Weaknesses |
|---|---|---|---|
| Something you know | Knowledge | Password, PIN, answer to a question | Can be stolen, guessed, reused, phished and shared without leaving a trace |
| Something you have | Possession | FIDO2 key, authenticator app, card, phone | Gets lost, gets stolen; SMS can be intercepted; codes can be phished |
| Something you are | Biometrics | Fingerprint, face | Cannot be changed if compromised; false positives and negatives; in practice it unlocks a device, it does not authenticate to the service |
| (Contextual) | Where and how | IP, country, known device, time | Not a factor on its own: it is a risk signal that modulates how much is demanded |
Three clarifications that correct frequent misunderstandings:
- Biometrics almost never travel to the server. When Iván unlocks his phone with his fingerprint, the biometric data does not leave the device: it unlocks a private key stored in the secure hardware. What the service receives is a cryptographic proof. That distinction is what makes passkeys robust rather than a privacy problem.
- Security questions are not a factor, they are a worse password: the answer is usually public or guessable, and you cannot change the name of your childhood school.
- Context does not replace a factor, but it is enormously valuable in combination: requiring reauthentication when the country or the device changes is what turns a stolen token into a useless one.
- Passwords: what the guidance says today
The recommendations changed substantially over the last decade, and many organisations are still applying the 2005 ones — which make security worse. The current reference is the NIST SP 800-63B guidance.
| Traditional practice | Current recommendation | Why it changed |
|---|---|---|
| Minimum 8 characters with upper case, lower case, digits and symbols | Minimum 12-15 characters, with no composition requirement | Mandatory complexity produces Company2026! predictably; length provides far more resistance |
| Expiry every 90 days | No forced expiry, unless there is an indication of compromise | Mandatory rotation generates trivial variations (…2026! → …2027!) and increases the use of written notes |
| Forbid pasting into the field | Always allow pasting | Forbidding it prevents the use of password managers, which is what really helps |
| Security questions as a fallback | Remove them | Public or guessable answers |
| Password hints | Remove them | They are a partial leak of the password |
| — | Check against lists of leaked passwords | It cuts off credential stuffing and dictionary attacks at the root |
| — | Allow all characters, including spaces and unicode | Makes long passphrases easier |
| — | Maximum length ≥ 64 characters | A low maximum gives away that the password might not be stored properly |
Checking against leaked lists is the single most effective measure in this table, and the least implemented. It stops somebody choosing a password that is already in the dictionaries attackers use. It is implemented without sending the password anywhere: the first characters of the hash are sent and the rest is compared locally (the k-anonymity model).
Password managers. For Nimbus they are mandatory, and the argument is simple: they make it possible to do the one thing that really matters — a long, random, per-service password — without memorising anything. The usual objection ("what if the manager is compromised?") ignores the real alternative, which is not memorising 40 strong passwords: it is reusing three weak ones. A manager with a strong master password and MFA is, by an enormous margin, the safest option. An added and little-known benefit: the manager does not fill in credentials on a domain that does not match, which turns it into a passive phishing detector.
The secure storage of passwords on the server — why they are never kept in the clear or with a plain hash, and what bcrypt, scrypt and Argon2 are, with their cost factor and their salt — is studied in detail in 03-04. Here the rule is enough: Nimbus never stores recoverable passwords, and no legitimate process can give a customer back their current password.
- MFA ordered by phishing resistance
MFA is the most cost-effective measure in the lesson, but not all forms of MFA are worth the same. The decisive difference is whether the second factor can be captured and reused by an interposed attacker.
| Method | How it works | Phishing resistance | Other weaknesses | Use at Nimbus |
|---|---|---|---|---|
| SMS | A 6-digit code by message | Very low: the code is typed in and can be relayed in real time | SIM swapping, interception, dependence on coverage | Only as a last resort; better than nothing |
| A code or link to the mailbox | Very low | If the mail falls, everything falls | Avoid | |
| TOTP (authenticator app) | A 6-digit code derived from a shared secret and the time | Low-medium: it is still a code that can be asked for | The secret can be copied at set-up; 30 s window | The acceptable minimum for normal accounts |
| Simple push | A notification with "Approve / Deny" | Low: vulnerable to MFA fatigue (02-03) | Approval with no context | Insufficient on its own |
| Push with number matching | Shows a number on the screen that has to be entered in the app | Medium: it requires looking at the original device | Still phishable with effort | Acceptable |
| FIDO2 / WebAuthn and passkeys | Public key cryptography bound to the domain | High: phishing-resistant by design | Cost of the physical key; backup management | The target for all administrative accounts |
| Client certificate | A certificate on the device | High | PKI management (03-06) | For service-to-service |
5.1 Why FIDO2 is qualitatively different
All the previous methods share one underlying defect: they produce a secret the user transmits. If the user is on a fake website, they transmit the secret to the attacker, who relays it to the real site within seconds. That is exactly what reverse-proxy phishing kits do, and it is why "we have MFA" stopped being a sufficient answer.
FIDO2 breaks that scheme with two properties:
- The private key never leaves the device. There is no secret the user could hand over, not even if they wanted to.
- The signature is bound to the domain (origin binding). The browser includes the page's real origin in the operation. If the user is on
nimbusreservas.example.portal-facturas.example, the key produces a signature for that domain, which the legitimate server rejects. The attack does not fail because the user notices: it fails because it is cryptographically invalid.
Put another way: with TOTP or SMS, the defence depends on the user spotting the fake site. With FIDO2, the defence works even if the user spots nothing. That is the difference between a measure that depends on human judgement and one that does not. Remember the principle from 02-03: if your defence requires 38 people to get it right every time, you have no defence.
Passkeys are FIDO2 with the key synchronised between the user's devices through their ecosystem, which removes the main friction (losing the key) in exchange for trusting the synchroniser.
5.2 TOTP verification, explained
Even if the target is FIDO2, TOTP remains the realistic minimum for many accounts. It is worth understanding how it works inside.
import hmac, hashlib, struct, time
def totp_code(secret: bytes, at_time: int | None = None,
step: int = 30, digits: int = 6) -> str:
"""Generates the 6-digit TOTP code (RFC 6238)."""
# 1. The "counter" is the number of 30 s intervals since 1970.
# Issuer and verifier compute it separately: nothing is transmitted.
counter = int((at_time or time.time()) // step)
# 2. HMAC-SHA1 of the counter with the shared secret.
# HMAC guarantees that only someone who knows the secret can
# generate the value (03-04).
digest = hmac.new(secret, struct.pack(">Q", counter), hashlib.sha1).digest()
# 3. "Dynamic truncation": the last 4 bits say where to start reading.
offset = digest[-1] & 0x0F
value = struct.unpack(">I", digest[offset:offset + 4])[0]
value &= 0x7FFFFFFF # the sign bit is discarded
# 4. Reduced to 6 digits.
return str(value % (10 ** digits)).zfill(digits)
def verify_totp(secret: bytes, code: str, user_id: int,
window: int = 1) -> bool:
"""Correct verification: clock tolerance, constant time and
protection against reuse."""
now = time.time()
# (a) The current interval and one before/after are accepted: clocks
# are not perfectly synchronised. Larger windows weaken it.
for delta in range(-window, window + 1):
expected = totp_code(secret, now + delta * 30)
# (b) Constant-time comparison: prevents deducing the code by
# measuring how long the comparison takes to fail.
if hmac.compare_digest(expected, code):
# (c) A code may be used ONCE only: without this, a captured
# code works for the next 30-90 s.
if cache.get(f"totp:{user_id}:{code}"):
return False
cache.set(f"totp:{user_id}:{code}", "1", expire=120)
return True
return FalseThe four decisions that separate this implementation from a naive one:
- (a) A bounded tolerance window. With no tolerance, users whose clock is slightly off cannot get in. With a large window (say ±5), the code is valid for almost five minutes and the real-time attack becomes trivial. ±1 is the usual balance.
- (b)
hmac.compare_digest. A normal string comparison stops as soon as it finds a difference, and that time is measurable. In constant time nothing leaks. - (c) Single-use consumption. It is the control most often forgotten: without it, a code captured by phishing remains valid for the rest of the window, which is exactly what the interposed attacker needs.
- The secret is stored encrypted in the database, not in the clear: anyone who reads it can generate codes indefinitely.
And the honest conclusion: however well implemented it is, TOTP is still phishable, because the user types a code into a page. That is why the direction of travel for Nimbus is TOTP as the general minimum and FIDO2 for anything administrative.
5.3 MFA fatigue and its fix
Connecting with 02-03: notification bombing exploits the fact that approving is a button with no context. The fixes, in order of effectiveness:
- Number matching: it forces you to look at the originating screen and enter a number, which makes approving out of inertia impossible.
- Context in the notification: application, approximate location, device. A request from another country is obvious.
- Attempt limits: after 3 denied or ignored requests, the account is locked and an alert fires.
- Alert to security on bursts: a burst of MFA requests means somebody already has the password, whether it gets approved or not. It is a high-value signal that hardly anybody watches.
- SSO, federation and identity protocols
6.1 Why SSO is a security measure
With single sign-on, the user authenticates once to an identity provider and reaches every federated application.
| Advantage | Why it matters at Nimbus |
|---|---|
| One leaving process deactivates everything | Drastically shortens the list of boxes in section 2.3 |
| MFA configured once, applied everywhere | No need to set it up service by service |
| A single point of logging | Every log-in in one log, correlatable |
| Centralised policies | Requiring a known device or risk-based reauthentication, in one place |
| Fewer passwords | Less reuse, less effective phishing |
The counterweight, which has to be accepted consciously: the identity provider becomes the most critical asset of all. If it goes down, nobody gets into anything; if it is compromised, everything is compromised. Hence: mandatory FIDO2 MFA for its administrators, exhaustive logging, and an emergency access procedure with credentials kept offline for the case of unavailability.
6.2 OAuth 2.0 versus OpenID Connect: the exact difference
It is the most widespread confusion in the domain, and it produces insecure systems.
| OAuth 2.0 | OpenID Connect (OIDC) | |
|---|---|---|
| What it is for | Authorisation: giving an application limited access to resources on the user's behalf | Authentication: establishing who the user is |
| Question it answers | "May this app read your calendar?" | "Who are you?" |
| What it hands over | An access token: a key with permissions | An ID token (a signed JWT) with the identity |
| Who consumes it | The API that receives the token | The application doing the log-in |
| Relationship | It is the foundation | It is a layer on top of OAuth 2.0 |
The sentence to fix in your mind: OAuth 2.0 is not an authentication protocol. Using an access token as proof of identity is a classic mistake: that token says what can be done, not who you are, and it might have been issued for a different application. If what you need is "who is this user", use OIDC and validate the ID token.
SAML is the veteran: the same objective as OIDC (federated authentication), based on XML and signed assertions, very widely deployed in the corporate world. For new integrations OIDC is preferred for its simplicity and its natural fit with mobile applications and SPAs; SAML is still unavoidable when a large corporate customer demands it, and that is a common request for a B2B SaaS like Nimbus.
6.3 The correct flow for the Nimbus SPA: Authorization Code + PKCE
sequenceDiagram
participant U as User
participant SPA as Nimbus SPA
participant IDP as Identity provider
participant API as Nimbus API
SPA->>SPA: Generates a random code_verifier<br/>and code_challenge = SHA256(verifier)
SPA->>U: Redirects to the IdP with code_challenge
U->>IDP: Authenticates (password + FIDO2)
IDP->>IDP: Validates credentials and consent
IDP->>SPA: Redirects with a single-use CODE
SPA->>IDP: Exchanges code + code_verifier
IDP->>IDP: Checks SHA256(verifier) == challenge
IDP->>SPA: ID token (who you are) + Access token (what you can do)
SPA->>API: GET /api/v1/reservas<br/>Authorization: Bearer <access token>
API->>API: Validates signature, iss, aud, exp and scope
API->>SPA: 200 with the data of the user's tenant
Why PKCE is essential and not an optional extra. The authorisation code travels through the browser, and in a public application (an SPA or a mobile app) there is no way of keeping a client secret: anyone can decompile the app or read the JavaScript. If an attacker intercepts the code, they could exchange it for tokens.
PKCE prevents this with a simple idea: the application generates a random value (code_verifier), sends only its hash when starting the flow, and presents the original value when exchanging. Whoever does not have the code_verifier cannot use the stolen code, and that value never leaves the browser that started the flow.
The four common mistakes in this flow:
- Using the implicit flow (tokens in the URL): obsolete and discouraged.
- Storing the tokens in
localStorage: reachable from JavaScript, and therefore stealable with an XSS. AHttpOnlycookie managed by a server-side intermediary is preferable. - Not validating
audin the API: accepting a token issued for another application. - Not validating the
stateparameter: it is the CSRF protection of the authentication flow itself.
- Sessions and tokens: cookies and JWTs
7.1 Secure cookies
Set-Cookie: session=aB3xK9...; HttpOnly; Secure; SameSite=Lax;
Path=/; Max-Age=3600; Domain=app.nimbusreservas.example| Attribute | What it does | Which attack it stops |
|---|---|---|
HttpOnly |
JavaScript cannot read it | Session theft through XSS |
Secure |
Only sent over HTTPS | Capture in transit |
SameSite=Lax |
Not sent on requests originating from another site | CSRF (02-02) |
Max-Age=3600 |
Short life with renewal | Pass-the-cookie: reduces the window of a stolen cookie |
Narrow Path and Domain |
Minimal scope | Stops the cookie travelling to unnecessary subdomains |
The __Host- prefix in the name |
Forces Secure, Path=/ and the absence of Domain |
Cookie fixation from a compromised subdomain |
SameSite=Strict versus Lax: Strict is safer but breaks navigation from external links (the user arrives at the panel and appears not to be logged in). Lax is the usual balance; for sensitive operations it is combined with an anti-CSRF token.
7.2 JWT: structure and what not to put inside
A JWT has three parts separated by dots: header, payload and signature, encoded in base64url.
eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYtMDMifQ.eyJpc3MiOiJodHRwczovL2lkc...
└──── header ────┘ └──────── payload ────────┘ └── signature ──┘{
"iss": "https://identidad.nimbusreservas.example",
"sub": "usr_8812",
"aud": "https://api.nimbusreservas.example",
"exp": 1774000000,
"iat": 1773999100,
"jti": "9f2a4c1e-77bb-4a10-9c31-7d2e5a6b1c04",
"tenant_id": 42,
"roles": ["reception"]
}The first thing to understand, and the most misinterpreted: a JWT is signed, not encrypted. Anyone holding it can read its contents with no key at all. The signature guarantees integrity and origin, not confidentiality.
What must NEVER go inside a JWT:
| Do not include | Why |
|---|---|
| Passwords or secrets | It is text readable by anyone |
| Sensitive personal data (patient name, medical service) | It travels on every request, is written to logs and proxies, and stays in the browser |
| Card or account numbers | Same reason, with specific obligations attached |
| Data that changes often | The token is a snapshot: if a role is revoked, the old token keeps saying the opposite |
| Large structures | The JWT travels on every request: it costs performance |
What it should carry: a subject identifier (sub), the tenant, the minimal roles or scopes, and always iss, aud, exp, iat and jti.
7.3 Correct validation versus naive validation
# ============ NAIVE AND VULNERABLE VALIDATION ============
import jwt
def user_from_token_BAD(token: str):
# (1) No signature verification: anyone can forge any token they like
claims = jwt.decode(token, options={"verify_signature": False})
# (2) No check of expiry, issuer or audience
# (3) The roles inside are simply trusted
return User(id=claims["sub"], tenant=claims["tenant_id"],
roles=claims["roles"])The three flaws and why each is exploitable:
- With no signature verification, an attacker composes a JWT with
"roles": ["admin"]and"tenant_id": 7and walks in as administrator of any clinic. The historical variant of this flaw is accepting thenonealgorithm that the token itself declares in its header: the token'salgmust never be trusted to decide how to validate it. - With no
expcheck, a token stolen a year ago still works. Withoutissandaud, a valid token issued by another system or for another application is accepted here. - Blindly trusting the roles in the token means that a permission change or a leaver has no effect until the token expires.
# ============ CORRECT VALIDATION ============
import jwt
from jwt import PyJWKClient
from fastapi import HTTPException
JWKS = PyJWKClient("https://identidad.nimbusreservas.example/.well-known/jwks.json")
ISSUER = "https://identidad.nimbusreservas.example"
AUDIENCE = "https://api.nimbusreservas.example"
def user_from_token(token: str):
try:
# (1) The public key is fetched from the issuer according to the
# header's 'kid'. It allows keys to be ROTATED without
# redeploying the API.
key = JWKS.get_signing_key_from_jwt(token).key
claims = jwt.decode(
token,
key,
algorithms=["RS256"], # (2) algorithm FIXED by us,
# never the one the token says
issuer=ISSUER, # (3) who issued it
audience=AUDIENCE, # (4) who it was issued for
options={"require": ["exp", "iat", "iss", "aud", "sub", "jti"],
"verify_exp": True},
leeway=30, # (5) clock tolerance: 30 s
)
except jwt.ExpiredSignatureError:
raise HTTPException(401, "Session expired")
except jwt.InvalidTokenError:
raise HTTPException(401, "Invalid token")
# (6) REVOCATION: a valid signature is not enough. We check that the
# token is not on the revoked list (leaver, log-out, theft).
if cache.get(f"revoked:{claims['jti']}"):
raise HTTPException(401, "Session revoked")
# (7) SENSITIVE permissions are resolved against the source of truth,
# not against whatever a token issued 50 minutes ago says.
permissions = load_current_permissions(claims["sub"], claims["tenant_id"])
return User(id=claims["sub"], tenant=claims["tenant_id"],
roles=permissions, jti=claims["jti"])The seven decisions, explained:
- Keys from JWKS by
kid. It allows the signing key to be rotated without touching the API. Hard-coding the public key turns rotation into a deployment, and what is hard does not get done. algorithms=["RS256"]fixed. It closes off the algorithm confusion attack (for example, passing the RSA public key off as an HMAC secret).- and 4.
issuerandaudience. Withoutaud, a legitimate token for another service from the same issuer would be accepted by the Nimbus API. leeway. It avoids spurious failures caused by clock drift; 30 s is reasonable, minutes are not.- Revocation by
jti. This is the JWT's structural weak point: being self-contained, it is valid until it expires even if the user has left. A revocation list with the same lifetime as the token solves the problem at minimal cost. - Current permissions from the source of truth. The roles in the token serve as a quick hint; sensitive decisions are taken with up-to-date data.
7.4 Token lifetimes and refresh
| Token | Recommended life | Where it is kept | Notes |
|---|---|---|---|
| Access token | 5-15 minutes | The application's memory | Short because it is hard to revoke |
| Refresh token | Hours or days, with rotation | HttpOnly Secure SameSite cookie |
Each use issues a new one and invalidates the previous one |
| Service token (API) | 90 days maximum, automated rotation | Secrets manager | Never without an expiry |
Refresh rotation with reuse detection, which is the elegant piece: if a refresh token that has already been used is presented again, it means somebody holds a stolen copy. The correct response is to invalidate the whole token family of that session and force reauthentication. It is a theft detection mechanism that works with no extra infrastructure.
7.5 The non-expiring token of 01-04, fixed
Recall the finding: the nimbus-deploy-bot service account with a personal token with no expiry and write permissions on every repository. It was also the first link of the chained attack of 02-02.
Everything that is wrong with it:
| Problem | Consequence |
|---|---|
| No expiry | Stolen once, it works forever |
| Total scope over every repository | The compromise is not bounded |
| Tied to a personal bot-type account | There is no clear owner and no traceability |
| Stored in the CI configuration and probably on laptops | Multiple copies, multiplied surface |
| No reviewed usage log | Nobody would notice anomalous use |
| No rotation | It has never changed since it was created |
The fix, in order of preference:
- Remove it and use short-lived credentials from the CI itself (a short-lived federated identity issued for each run). The best secret is the one that does not exist.
- If that is not possible: an application token with per-repository scope, a 90-day expiry, automated rotation and storage in the secrets manager.
- In any case: an alert on any use from an unexpected IP and a quarterly review of live tokens.
-- Hygiene query: tokens with no expiry or with no recent use.
-- It should run monthly and ALWAYS return zero rows.
SELECT id, name, owner, created_at, last_used, scope
FROM api_tokens
WHERE expires_at IS NULL -- never expire
OR expires_at > now() + interval '90 days' -- excessive life
OR last_used < now() - interval '30 days' -- created and forgotten
ORDER BY created_at;Tokens unused for 30 days are as dangerous as eternal ones, and for the same reason as orphaned accounts: nobody would miss them if somebody started using them.
- Authorisation models and Nimbus's RBAC
Authentication answers "who you are". Authorisation answers "what you can do", and it is where most serious incidents happen.
| Model | How it decides | Advantage | Drawback | Example |
|---|---|---|---|---|
| DAC (discretionary) | The resource's owner decides who gets access | Flexible, intuitive | Ungovernable at scale; the origin of "anyone with the link" sharing | The payroll sheet shared by Sara (01-04) |
| MAC (mandatory) | A central, immutable policy, based on classification labels | Very strict | Rigid and expensive | Military environments; SELinux |
| RBAC (role-based) | Permissions attached to roles, roles assigned to people | Simple, auditable, scalable | It does not express conditions (time, location, ownership) | The Nimbus model |
| ABAC (attribute-based) | Rules over user, resource and context attributes | Very expressive and granular | Hard to reason about and to audit | "Only from Spain, during working hours, if the resource belongs to their tenant" |
| ReBAC (relationship-based) | Based on the relationship between subject and object in a graph | Natural for sharing and hierarchies | Requires specific infrastructure | "They can see it because they are the practitioner assigned to that appointment" |
What suits Nimbus: RBAC as the base, with attributes for the conditions. It is the hybrid model almost everybody uses: roles define the bulk of the permissions and a few contextual rules cover what roles do not express (the tenant, working hours, the device).
8.1 Nimbus's RBAC design
Customer roles (inside a clinic or gym):
| Role | Can | Cannot |
|---|---|---|
| Centre administrator | Manage their centre's users, see the whole diary, configure services, see billing | Leave their tenant; reach another centre's data |
| Reception | Create, modify and cancel appointments; look up contact details | See clinical notes; export in bulk; manage users |
| Practitioner | See their diary and the notes of their patients | See other practitioners' diaries; manage users; billing |
Nimbus internal roles:
| Role | Can | Cannot |
|---|---|---|
| Support (Rubén) | See ticket metadata; impersonate a user only with justification and approval, with logging | Reach customer data without a ticket; export; see clinical notes |
| Development (Iván) | Full access to pre-production with pseudonymised data | Access production without explicit, temporary approval |
| Systems (Lucía) | Administer infrastructure; access production with MFA and logging | Read business data routinely; delete audit records |
| Administration (Sara) | Billing and HR | Reach end-customer data |
# roles.yaml - declarative definition of the permission matrix.
# The benefit of having it in the repository: it is reviewed like code,
# it is versioned and any change leaves a trace in the history.
roles:
centre_admin:
inherits: [reception]
permissions:
- users:read
- users:create
- users:deactivate
- billing:read
- config:write
conditions:
tenant: own # never outside their tenant
reception:
permissions:
- appointments:read
- appointments:create
- appointments:modify
- appointments:cancel
- customers:read_contact # contact YES, clinical notes NO
conditions:
tenant: own
mass_export: denied
practitioner:
permissions:
- appointments:read_own
- clinical_notes:read_own
- clinical_notes:write_own
conditions:
tenant: own
scope: assigned_diary # attribute: only THEIR appointments
nimbus_support:
permissions:
- tickets:read
- tenant_metadata:read
- impersonate_user # high-risk action
conditions:
impersonate_user:
requires_ticket: true
requires_approval: second_operator
max_duration: 30m
logging: mandatory
notify_customer: true
nimbus_systems:
permissions:
- infrastructure:administer
- deployment:execute
conditions:
mfa: fido2
production_access: just_in_time # requested, expires by itself
session_logging: mandatory
denied:
- audit:delete # NOBODY may delete audit recordsFour decisions in this design that deserve attention:
practitioneruses an attribute (assigned_diary), not a role. "Their" patients cannot be expressed with roles alone: it is a relationship between the user and the resource. This is where pure RBAC falls short and ABAC/ReBAC gets added.impersonate_useris an explicit permission with conditions, not an implicit capability of the support role. It is the fix for the "view as customer" feature that allowed access to 40 clinics in the 02-02 exercise. And the customer is notified: transparency is itself a control.audit:deleteappears as an explicit denial for everybody, including systems. It is the separation of duties from 01-03: whoever administers cannot delete the proof of what they did, consistent with theINSERTwithoutUPDATEfornimbus_api.- The file lives in the repository. A permission change goes through review and stays in the history. Permissions changed by hand in a console leave no comprehensible trace.
And in the database, the counterpart of the same design, consistent with the roles from 01-03:
-- Read-only role for the support panel: it never sees clinical notes
CREATE ROLE nimbus_support_role NOLOGIN;
GRANT USAGE ON SCHEMA public TO nimbus_support_role;
-- A view that EXCLUDES the sensitive columns: least privilege is
-- implemented in the schema, not in trusting the code
CREATE VIEW v_support_tickets AS
SELECT b.id, b.tenant_id, b.date_time, b.status, c.name AS customer
FROM bookings b JOIN end_customers c ON c.id = b.customer_id;
-- deliberately without b.clinical_notes and without c.dni
GRANT SELECT ON v_support_tickets TO nimbus_support_role;
REVOKE ALL ON bookings, end_customers, payroll FROM nimbus_support_role;
- Multi-tenancy and isolation
Nimbus is multi-tenant: a single database with the data of every clinic. Isolation between customers is the product: if it fails, there is no business.
You have already worked on the two pieces — the tenant_id filter that fixed the IDOR in 01-01 and the row level security (RLS) that makes it mandatory at the engine in 01-03. What belongs here is how isolation fits into access control:
| Layer | What it guarantees | What happens if it alone fails |
|---|---|---|
Token with tenant_id |
The API knows which tenant the request belongs to | If it is tampered with, the following layers stop it |
| Filter in the query | Every query is limited to the tenant | One oversight exposes somebody else's data |
| RLS in PostgreSQL | The engine enforces it even if the code forgets | It is the safety net |
| Least-privilege DB role | Bounds the damage of an injection | It limits the reach |
Auditing with tenant_id |
Makes cross-tenant access detectable | It is the detective layer |
The design rule, in one sentence: the tenant_id never comes from a parameter sent by the client; it always comes from the token validated on the server. An endpoint that accepts ?tenant_id= from the client is an IDOR by another name.
And the check worth automating: a test in CI that, with one tenant's token, tries to reach another tenant's resource and requires a 404. It is a five-line test that would have caught the original IDOR before it reached production.
- Privileged, service and third-party accounts
Accounts with elevated permissions are the ultimate objective of almost any attack. They deserve different treatment from the rest.
10.1 Separating the administrative account
The problem: if Lucía browses, reads mail and administers the infrastructure with the same account, a successful phishing attack against her mail hands over control of all of production directly.
The solution: two identities for the same person.
| Account | Use | Restrictions |
|---|---|---|
lucia@nimbusreservas.example |
Mail, documents, browsing, meetings | No administrative permissions |
adm.lucia@nimbusreservas.example |
Administration only | No mail and no browsing; FIDO2 MFA mandatory; only from a managed machine; session logged; just-in-time access |
The cost is a moderate daily inconvenience; the benefit is that the most likely vector (phishing the mailbox) stops leading to the most critical asset.
10.2 PAM and just-in-time access
PAM (privileged access management) groups together the practices for controlling the most powerful accounts:
| Practice | What it provides | Application at Nimbus |
|---|---|---|
| Credential vault | Nobody knows the administrative passwords: they are requested | The PostgreSQL superuser password, the cloud provider's root account |
| Just-in-time access | The permission does not exist until it is requested, and expires by itself | Production access granted for 2 hours with a justification |
| Session recording | Traceability of what was done | Bastion host sessions |
| A second person's approval | Separation of duties | Destructive operations or access to customer data |
| Automatic rotation | Reduces the window of a leaked credential | Service and database credentials |
The mental shift just-in-time proposes: instead of asking "who should have permanent access?", you ask "why would anybody have permanent access?". In a well-designed system, the normal state of an administrative account is without privileges; privileges are granted for minutes and disappear on their own. It is the least privilege principle of 01-03 taken to the axis of time: not only the minimum of permissions, but also the minimum of duration.
10.3 The consultancy's remote access (A-19)
This is the asset with critical criticality and Marta as owner in the 01-04 inventory, and it is the exact pattern of the Target incident we will analyse in 02-06.
Current situation:
| Aspect | State | Risk |
|---|---|---|
| Accounts | Shared by several engineers | No traceability: it is not known who did what |
| Permissions | Full administrative and permanent | No limit of scope and no limit of time |
| MFA | No | One leaked password gives full access |
| Logging | Not reviewed | Misuse would go unnoticed |
| Expiry | None | The access will outlive the contract |
| Contract | No detail of scope or obligations | No basis for demanding anything |
Target configuration:
- Named accounts per consultancy engineer. No exceptions: without naming there is no non-repudiation.
- Mandatory MFA on every access.
- Just-in-time access: no privileges by default; they are requested with a reason, approved by Lucía or Marta and expire within hours.
- Bounded scope: only the systems they genuinely need, never "administrator of everything".
- Logged sessions, reviewed by sampling.
- Automatic expiry of the access aligned with the contract, with a quarterly review.
- An alert on any access outside the agreed window.
Note: the contractual obligations with the supplier — scope, confidentiality, incident notification, subcontracting, audit — and third-party risk management as a process are covered in 04-04, and their data protection implications in 06-03. They must be validated with legal advice.
10.4 Service accounts
The accounts used by systems (not by people) are the usual blind spot: nobody claims them and nobody reviews them.
| Rule | Reason |
|---|---|
| One account per service, never shared between several | It allows the damage to be bounded and who did what to be known |
| A named human owner | Somebody has to answer for it at recertification |
No interactive access (NOLOGIN, no shell) |
It must not be usable as a human way in |
| Minimal, explicit permissions | Like the nimbus_api and nimbus_reports roles of 01-03 |
| Short-lived or rotated credentials | Reduces the window of a leak |
| Logged use with a known baseline | A service has a very predictable pattern: any deviation is suspicious |
- Recertification: the periodic access review
Everything above degrades over time. Recertification is the periodic review in which the owner of each asset confirms — or withdraws — the access that has been granted.
How it is done at Nimbus, sustainably:
| Element | Decision |
|---|---|
| Frequency | Every six months for normal access; quarterly for privileged and third-party access |
| Who reviews | The owner of the asset from the 01-04 inventory, not the technical team |
| What is reviewed | The list of people with access, the level, the date it was granted and the date of last use |
| Default rule | What is not explicitly confirmed is withdrawn. If somebody has to act in order to withdraw, it never gets withdrawn |
| Output | A signed, dated list: it is the evidence an audit will ask for (06-04) |
The piece of data that makes the review efficient is the date of last use. A permission unused for six months is almost never necessary, and presenting the list sorted by that field turns a tedious review into a quick decision.
-- Input for recertification: access with no recent use.
-- Sent to each asset's owner so they can confirm or withdraw it.
SELECT u.email,
u.role,
a.name AS asset,
p.granted_at,
p.granted_by,
MAX(l.ts) AS last_used,
CASE WHEN MAX(l.ts) IS NULL THEN 'NEVER USED'
WHEN MAX(l.ts) < now() - interval '180 days' THEN 'UNUSED 6 MONTHS'
ELSE 'ACTIVE' END AS status
FROM permissions p
JOIN users u ON u.id = p.user_id
JOIN assets a ON a.id = p.asset_id
LEFT JOIN access_log l ON l.user_id = u.id AND l.asset_id = a.id
GROUP BY u.email, u.role, a.name, p.granted_at, p.granted_by
HAVING MAX(l.ts) IS NULL OR MAX(l.ts) < now() - interval '180 days'
ORDER BY a.name, last_used NULLS FIRST;A "NEVER USED" permission is the best possible news in a recertification: it is withdrawn without discussion, with no risk of breaking anything and with an immediate benefit in surface reduction.
Common Mistakes and Tips
Common mistakes:
- Only offboarding the mail account. It is the mistake of section 2.3: eight accesses stay alive in non-federated systems.
- Deactivating the account without revoking sessions and tokens. An issued session keeps working; a token with no expiry, forever.
- Copying another employee's permissions when onboarding. It propagates accumulated privileges nobody decided to grant.
- Treating all MFA as equivalent. SMS and FIDO2 differ in whether the phishing attack works or not.
- Forcing password expiry every 90 days. It produces trivial variations and passwords written on paper; the current guidance advises against it unless there is an indication of compromise.
- Using OAuth 2.0 as if it were authentication. An access token says what can be done, not who you are. For identity, OIDC and validation of the ID token.
- Storing tokens in
localStorage. An XSS steals them. Use anHttpOnlycookie or a server-side intermediary. - Trusting a JWT without verifying the signature,
exp,issandaud, or letting the token choose the algorithm. - Putting personal data inside the JWT. It is signed, not encrypted: anyone can read it.
- Accepting
tenant_idas a client parameter. It is an IDOR by another name. - Granting permanent privileged access because "it is more convenient". The normal state of an administrative account should be without privileges.
- Recertifying with the rule "we withdraw what somebody asks us to withdraw". By default, what is not confirmed must be withdrawn.
Tips:
- Start with what pays best: FIDO2 on the administrative accounts and an expiry on every token. Two afternoons of work that close the two most exploited vectors.
- Write out the leaver checklist from section 2.3 and run it on the last day, not afterwards. Keep signed evidence.
- Separate the administrative account from the personal one for Lucía and Marta. It is inconvenient for a week and it protects for years.
- Put the
roles.yamlfile in the repository and treat it as code: review, history and controlled deployment. - Add the tenant isolation test to CI. Five lines that permanently guard against the most expensive failure a SaaS can have.
- Run the non-expiring token query today. If it returns rows, you already have this week's task.
- At recertification, always sort by date of last use: it turns a tedious review into one-second decisions.
Exercises
Exercise 1 — Designing an employee's offboarding and spotting what is missing
Iván leaves Nimbus. Lucía does the following:
- Deactivates his mail account.
- Removes him from the internal messaging channel.
- Recovers the laptop.
- Removes him from the GitHub repository as a collaborator.
Two months later, during the quarterly review, activity is spotted: somebody has run a deployment at 4:10 in the morning.
Required: (a) list at least six accesses that are probably still alive and explain why each is dangerous; (b) write the complete leaver checklist that should have been applied; (c) explain how a deployment could have been run if the GitHub account had been removed, with two different technical hypotheses; (d) propose three structural measures that reduce this risk regardless of the discipline of whoever runs the offboarding.
Exercise 2 — Auditing a token implementation
This is the authentication code of a new version of the Nimbus API:
import jwt, datetime
from fastapi import HTTPException
SECRET = "nimbus-2026"
def issue(user):
payload = {
"sub": user.email,
"name": user.full_name,
"dni": user.dni,
"tenant_id": user.tenant_id,
"roles": user.roles,
"internal_notes": user.notes,
}
return jwt.encode(payload, SECRET, algorithm="HS256")
def validate(token: str):
claims = jwt.decode(token, SECRET,
algorithms=["HS256", "none"],
options={"verify_exp": False})
return User(id=claims["sub"], tenant=claims["tenant_id"],
roles=claims["roles"])
@app.post("/api/v1/sesion")
def login(email: str, password: str):
u = authenticate(email, password)
if not u:
raise HTTPException(401, "User not found or incorrect password")
return {"token": issue(u), "expires": "never"}Required: (a) identify all the security problems, classifying them as critical, high and medium; (b) explain the concrete impact of the two most serious ones on the clinics' data; (c) rewrite issuance and validation correctly; (d) explain what changes if the API is deployed on three instances and a session has to be revoked immediately.
Exercise 3 — Authorising a new feature
Nimbus is launching occupancy reports by practitioner. Business requirements:
- The centre administrator sees the report for every practitioner in their centre.
- The practitioner sees only their own report.
- Reception does not get access to this feature.
- Nimbus support (Rubén) can see a centre's report only with an open ticket and the approval of a second operator, for 30 minutes at most, and the centre must be notified.
- Reports can be exported to CSV, with a maximum of 5,000 rows and 6 requests per hour per user.
- A practitioner who left more than 90 days ago must not appear.
Required: (a) decide which authorisation model or combination of models you would use and justify it; (b) write the corresponding fragment of roles.yaml; (c) write the Python code for the endpoint with all the authorisation checks, stating in comments which attack each one prevents; (d) give three events you would log and one alert you would build on them.
Solutions
Solution 1
(a) Accesses that are probably still alive:
| Access | Why it is dangerous |
|---|---|
| Cloud provider account (A-05) | Access to buckets, the database and the infrastructure itself; it is the asset almost everything else depends on |
| Personal GitHub token created by Iván | It survives the removal of the account: a token is an independent credential |
Personal PostgreSQL user (dev_ivan) |
Direct access to production data without going through the API or its controls |
SSH key in the servers' authorized_keys |
Access to the servers without central authentication; invisible to SSO |
| Secrets he knew (DB password, gateway keys, transactional e-mail credential) | They are still valid: somebody with no relationship to the company knows them |
| Non-federated SaaS tools (ticketing, monitoring, error tracker) | They contain customer data and production traces |
| Active sessions in browsers and the mobile app | Deactivating the account does not invalidate an issued session (pass-the-cookie, 02-03) |
| Local copies of data on his laptop or in personal storage | They do not even require access: the data has already left |
(b) The checklist from section 2.3, carried out on the last day, with signed evidence and with reassignment of ownership of his inventory assets.
(c) Two hypotheses for the night-time deployment:
- A live personal token. Iván created a GitHub token with write permissions and no expiry — the pattern of the 01-04 finding. Removing the account as a collaborator does not invalidate the token, which keeps authenticating against the service's API. This is the most likely hypothesis.
- An SSH key or infrastructure credential. The deployment did not go through GitHub: it was run directly against the server with an SSH key left in
authorized_keys, or with cloud provider credentials he knew.
In both cases, the lesson is the same: an identity is not just the account. It is the account plus every artefact it created, each of which lives independently.
(d) Three structural measures:
- Federate as much as possible with the identity provider, so that a single offboarding deactivates the maximum number of accesses. It reduces dependence on human discipline.
- Forbid by policy non-expiring tokens and personal SSH keys on servers, replacing them with short-lived credentials issued by CI and bastion access with a central identity. An artefact that does not exist cannot be forgotten.
- Quarterly recertification with the query from section 11, which catches whatever escaped the process. It is the safety net: it assumes the offboarding will fail at some point and limits how long it takes to notice.
Solution 2
(a) Problems found:
| Severity | Problem | Detail |
|---|---|---|
| Critical | algorithms=["HS256", "none"] |
Accepting none allows tokens with no signature to be forged: anyone becomes administrator of any tenant |
| Critical | SECRET = "nimbus-2026" in the code |
A weak, guessable secret present in the repository; it allows arbitrary tokens to be signed |
| Critical | verify_exp: False and "expires": "never" |
The tokens never expire: stealing one amounts to permanent access |
| High | No exp, iss, aud, iat or jti in the payload |
Impossible to bound validity, origin or recipient, or to revoke |
| High | dni, name and internal_notes inside the JWT |
The JWT is signed but not encrypted: personal data readable by anyone and present in logs and in the browser |
| High | No revocation mechanism | A leaver or a theft has no effect |
| Medium | HS256 with a shared secret | Any service that validates can also issue; with RS256 only the issuer signs |
| Medium | sub is the e-mail address |
A change of address breaks the identity; a stable identifier is better |
| Medium | An error message that distinguishes cases | "User not found or incorrect password" is acceptable, but a fully uniform message and a constant response time are preferable to avoid the user enumeration of 02-02 |
(b) Impact of the two most serious. With none accepted, an attacker builds a token with "tenant_id": 7 and "roles": ["centre_admin"], unsigned, and the API accepts it. Result: full access to the diary and the data of any clinic, with no credentials needed, no phishing and nothing else exploited. Combined with the absence of expiry, a token obtained just once — from a log, from a screenshot, from a laptop — gives indefinite access. In terms of the CIA triad, this is a total loss of confidentiality and integrity over data that reveals health information, and it triggers notification obligations (06-03).
(c) Correct code:
import jwt, uuid, datetime
from jwt import PyJWKClient
from fastapi import HTTPException
ISSUER = "https://identidad.nimbusreservas.example"
AUDIENCE = "https://api.nimbusreservas.example"
JWKS = PyJWKClient(f"{ISSUER}/.well-known/jwks.json")
PRIVATE_KEY = secrets_manager.read("jwt/private-key") # never in the code
def issue(user):
now = datetime.datetime.now(datetime.timezone.utc)
payload = {
"iss": ISSUER,
"aud": AUDIENCE,
"sub": str(user.id), # stable identifier, not the e-mail
"tenant_id": user.tenant_id,
"roles": user.roles, # quick hint; sensitive data reloaded
"iat": now,
"exp": now + datetime.timedelta(minutes=15), # short life
"jti": str(uuid.uuid4()), # identifier used to revoke
# no name, no dni, no internal notes
}
return jwt.encode(payload, PRIVATE_KEY, algorithm="RS256",
headers={"kid": "2026-03"})
def validate(token: str):
try:
key = JWKS.get_signing_key_from_jwt(token).key
claims = jwt.decode(token, key,
algorithms=["RS256"], # fixed, never from the token
issuer=ISSUER, audience=AUDIENCE,
options={"require": ["exp", "iat", "iss",
"aud", "sub", "jti"]},
leeway=30)
except jwt.ExpiredSignatureError:
raise HTTPException(401, "Session expired")
except jwt.InvalidTokenError:
raise HTTPException(401, "Invalid token")
if cache.get(f"revoked:{claims['jti']}"):
raise HTTPException(401, "Session revoked")
permissions = load_current_permissions(claims["sub"], claims["tenant_id"])
return User(id=claims["sub"], tenant=claims["tenant_id"],
roles=permissions, jti=claims["jti"])(d) Immediate revocation with three instances. A JWT is self-contained: the three instances validate it without consulting anyone, so a leaver has no effect until it expires. Solutions, which can be combined:
- A shared revocation list (Redis) consulted by
jti, with the same lifetime as the token: at most 15 minutes' worth of entries. Cost: one fast lookup per request; it is what the code above does. - A short access token life (5-15 min) with a rotating refresh. The real revocation is applied to the refresh, which is checked against the database: the maximum exposure window is the access token's lifetime.
- A per-user invalidation marker (
tokens_valid_from): any token with aniatearlier than that marker is rejected. It revokes all of a person's sessions with a single field, ideal for a leaver or a compromise.
The underlying trade-off: the JWT is fast and stateless, but revocation requires state. The practical solution is not to pick one extreme, but to shorten the token's life so that the window is acceptable and keep a revocation mechanism for the urgent cases.
Solution 3
(a) Model chosen: RBAC as the base + ABAC/ReBAC for scope and conditions. Justification:
- The three profiles (centre administrator, practitioner, reception) are roles: RBAC expresses them directly and auditably.
- "Only their own report" is not a role: it is a relationship between the user and the resource (the assigned practitioner). It requires ReBAC or an attribute.
- "With a ticket, approval, 30 minutes and notification" are contextual conditions: ABAC.
- "A practitioner who left more than 90 days ago" is a temporal attribute of the resource.
Pure RBAC would force a role to be created per practitioner, which is ungovernable. Pure ABAC would be expressive but very hard to audit. The hybrid is what real systems use.
(b) Fragment of roles.yaml:
new_permissions:
occupancy_reports:read_centre:
roles: [centre_admin]
conditions:
tenant: own
visible_practitioners: offboarded_less_than_90_days_ago
occupancy_reports:read_own:
roles: [practitioner]
conditions:
tenant: own
scope: practitioner_id == user.practitioner_id
occupancy_reports:export:
roles: [centre_admin, practitioner]
limits:
max_rows: 5000
requests_per_hour: 6
logging: mandatory
occupancy_reports:read_support:
roles: [nimbus_support]
conditions:
requires_ticket: true
requires_approval: second_operator
max_duration: 30m
notify_customer: true
logging: mandatory
denied_for: [reception](c) Endpoint with the checks:
@app.get("/api/v1/informes/ocupacion")
@limiter.limit("6/hour") # API abuse and application DDoS (02-02)
def occupancy_report(practitioner_id: int | None = None,
output_format: str = "json",
current_user = Depends(get_current_user)):
# 1. ROLE AUTHORISATION. Reception never gets this far.
# Prevents: vertical privilege escalation.
if not current_user.has_any(["centre_admin", "practitioner",
"nimbus_support"]):
raise HTTPException(403)
# 2. TENANT FROM THE TOKEN, NEVER FROM THE CLIENT.
# Prevents: cross-tenant IDOR (01-01) and cross access.
tenant = current_user.tenant_id
# 3. SCOPE ACCORDING TO ROLE (the ABAC/ReBAC part).
# Prevents: horizontal escalation between practitioners at the same centre.
if current_user.has("practitioner"):
if practitioner_id not in (None, current_user.practitioner_id):
raise HTTPException(404) # 404, not 403: it does not reveal existence
practitioner_id = current_user.practitioner_id
# 4. SUPPORT: strict conditions and traceability.
# Prevents: the abuse of "view as customer" from the 02-02 exercise.
if current_user.has("nimbus_support"):
session = support_sessions.current(current_user.id, tenant)
if not session or not session.approved_by_second or session.is_expired():
raise HTTPException(403, "Requires an approved, current ticket")
tenant = session.tenant_id # the tenant is set by the approval
notify_customer(tenant, current_user.id, "support_report_access")
# 5. BOUNDED QUERY: tenant filter, exclusion of leavers and LIMIT.
# Prevents: excessive data exposure and application-layer DDoS.
rows = db.execute("""
SELECT p.id, p.name, COUNT(b.id) AS appointments,
SUM(CASE WHEN b.status='completed' THEN 1 ELSE 0 END) AS completed
FROM practitioners p
LEFT JOIN bookings b ON b.practitioner_id = p.id
AND b.tenant_id = :t
WHERE p.tenant_id = :t
AND (p.offboarded_at IS NULL OR p.offboarded_at > now() - interval '90 days')
AND (:pid::int IS NULL OR p.id = :pid)
GROUP BY p.id, p.name
LIMIT 5000""",
{"t": tenant, "pid": practitioner_id}).fetchall()
# 6. AUDITING: who, what, how much, for whom.
record_audit(user_id=current_user.id, tenant_id=tenant,
action="occupancy_report",
output_format=output_format, record_count=len(rows),
practitioner_id=practitioner_id)
if output_format == "csv":
return export_csv(rows, maximum=5000)
return {"data": [dict(r) for r in rows]}(d) Three events to log and one alert:
| Event | Fields |
|---|---|
occupancy_report_viewed |
user_id, tenant_id, role, practitioner_id, record_count, output_format |
support_access_started |
user_id, tenant_id, ticket, approved_by, expires_at |
csv_export |
user_id, tenant_id, record_count, ip |
Alert: support access to more than 3 distinct tenants in 24 hours, or any support access with no associated ticket. It is exactly the signal that would have caught the support panel incident from the 02-02 exercise in hours instead of three days, and it is cheap to build because the fields are already in the log. A second useful alert: any CSV export outside the tenant's working hours, which takes advantage of the fact that Nimbus knows each centre's opening times.
Conclusion
You have worked through the domain that decides most incidents today. You know why identity is the new perimeter: none of Nimbus's relevant assets sits behind a network boundary, and the only thing that crosses every context is who you are. You have separated identification, authentication and authorisation precisely, and you have settled the trap that recurs throughout the course: most serious incidents are not authentication failures but authorisation failures. You have worked through the identity life cycle and its weak point, the leaving process, with the concrete case of the eight accesses that survive a mail deactivation, the checklist that prevents it and the two things that are always forgotten: revoking sessions and tokens, and rotating the shared secrets.
In passwords you take away the updated guidance — length over complexity, no forced expiry, allow pasting, and the most effective and least implemented measure: checking against leaked lists — and the definitive argument in favour of managers. In MFA you have learned the hierarchy that really matters, the one of phishing resistance, and the reason FIDO2 is qualitatively different: the key never leaves the device and the signature is bound to the domain, so that the defence works even if the user notices nothing. You have seen the inner workings of TOTP verification with its four decisions — a bounded window, constant-time comparison, single-use consumption and an encrypted secret — and the fix for MFA fatigue with number matching.
In federation you have settled the exact difference between OAuth 2.0, which authorises, and OpenID Connect, which authenticates, together with the Authorization Code + PKCE flow and the reason PKCE is essential in public applications. In sessions and tokens you have worked through the cookie attributes that neutralise XSS, CSRF and capture in transit, and you have compared a naive JWT validation with a correct one: signature verified with the issuer's key, the algorithm fixed by us, iss, aud and exp checked, revocation by jti and sensitive permissions reloaded from the source of truth. And you have fixed the non-expiring token of 01-04, the first link of the chained attack of 02-02.
In authorisation you have compared DAC, MAC, RBAC, ABAC and ReBAC and you have built Nimbus's real design: customer roles and internal roles in a versioned roles.yaml, with impersonate_user as a conditioned, notified permission, and audit:delete denied to everybody. You have closed off multi-tenant isolation with its golden rule — the tenant_id always comes from the token, never from the client — and its automated test in CI. And you have finished with privileged accounts: separating the administrative account, PAM and the just-in-time access that turns least privilege into least duration, the target configuration for the consultancy's access (A-19) and the periodic recertification whose decisive rule is that what is not confirmed is withdrawn.
You now have the module's complete armoury: you know what gets attacked, how, what defends against it and how access is controlled. What is missing is the reality check. In the module's final lesson, Cybersecurity Incident Case Studies (02-06), we will apply a method of analysis to real, public incidents — Target, WannaCry, Equifax, SolarWinds, Colonial Pipeline and a leak through misconfigured cloud storage — map each one to the concepts of the course, extract the patterns that recur without exception, and reconstruct hour by hour a fictitious ransomware incident at Nimbus that comes in through the consultancy's remote access, in order to answer the only question that matters: what would have changed the outcome.
Fundamentals of Information Security Course
Module 1: Introduction to Information Security
- Basic Concepts of Information Security
- Types of Threats and Vulnerabilities
- Principles of Information Security
- Assets, Attack Surface and Threat Actors
Module 2: Cybersecurity
- Definition and Scope of Cybersecurity
- Types of Cyber Attacks
- Social Engineering and Phishing
- Protection Measures in Cybersecurity
- Identity, Authentication and Access Control
- Cybersecurity Incident Case Studies
Module 3: Cryptography
- Introduction to Cryptography
- Symmetric Cryptography
- Asymmetric Cryptography
- Hash Functions, HMAC and Password Storage
- Cryptographic Protocols
- Key Management, Certificates and PKI
- Applications of Cryptography
Module 4: Risk Management and Protection Measures
- Risk Assessment
- Security Policies
- Security Controls
- Third-Party and Supply Chain Risk
- Incident Response Plan
- Disaster Recovery and Business Continuity
Module 5: Security Tools and Techniques
- Vulnerability Analysis Tools
- Monitoring and Detection Techniques
- Penetration Testing
- Network Security
- Application Security
- System Hardening and Endpoint Security
- Cloud and Container Security
Module 6: Best Practices and Regulations
- Best Practices in Information Security
- Security Regulations and Standards
- Personal Data Protection and GDPR in Practice
- Compliance and Auditing
- Training and Awareness
- Ethics, Legal Aspects and Responsible Disclosure
