What you'll learn: By the end of this lesson you will be able to secure QuickCart with sign-in, authorization, encryption and secrets, defend against injection, page a human only for symptoms users feel, explain how Kubernetes, infrastructure as code and canary releases make change routine, and turn the project's traffic numbers into a node count and a cost model.
A restaurant can have a wonderful kitchen and still not be ready to open. Someone decides who holds the till keys, cameras show what happened overnight, and the owner has a plan for the Friday rush.
QuickCart, the online grocery marketplace of this course, already scales and survives failures. This stage makes it safe to expose, visible while it runs and routine to change. It is an educational reference design, and every number is an illustrative assumption.
Where QuickCart is now
After Stage 9, QuickCart is seven services behind an API gateway, handling about 2,300 browsing requests and 58 checkouts per second at peak (illustrative; the arithmetic is in Stage 1). The weak points are now operational:
- Sign-in is home-grown: tokens have no scopes, last a long time and cannot be revoked per device.
- Database passwords sit in config files, and logs live on each server.
- Releases are hand-run scripts, and a bad one is noticed when a store owner phones.
- Nobody has planned how many servers a peak needs, or what they cost.
Figure 1. QuickCart at the start of Stage 10: sound design, weak operation.
How to read this: the top row is the Stage 8 request path. The red dashed arrows are the gaps this stage closes.
The core idea
Assume every request, credential and deploy can go wrong, and make the wrongness limited, visible and reversible. Limited: a stolen token reaches little, thanks to least privilege and short lifetimes. Visible: telemetry tells you before customers do. Reversible: a release can be undone fast.
Sign in and decide who may do what
Authentication and authorization
Authentication asks "who are you?"; authorization asks "what may you do?". Perfect sign-in with no authorization lets any shopper read another's order by changing an id in the URL, the flaw OWASP ranks first in its 2023 API list, broken object-level authorization. After sign-in, shoppers, store staff, couriers and other services each need a way to prove identity on later requests.
| Approach | How it works | Strength | Cost |
|---|---|---|---|
| Server-side session | Server stores state; browser holds a random id in a cookie | Instant revocation | Every request needs the shared session store (Stage 2) |
| Signed token (JWT) | Identity provider signs claims; any service verifies with a public key | Fast, stateless | Cannot be recalled before expiry; claims go stale |
| Opaque token | Random string; services ask the provider what it means (RFC 7662) | Instant revocation | One lookup per request |
Figure 2. Choosing a credential for each QuickCart client.
backend of its own?"} -->|yes| S["Server-side session
HttpOnly cookie"] Q1 -->|no| Q2{"Revocable in seconds?"} Q2 -->|yes| O["Opaque token
with introspection"] Q2 -->|no| Q3{"Many services verify
it independently?"} Q3 -->|yes| J["Short-lived signed token
plus refresh token"] Q3 -->|no| S
How to read this: QuickCart ends with a mix: a session cookie for the store dashboard, short-lived signed tokens for the mobile apps, opaque tokens for refunds.
OAuth 2.0, PKCE and OpenID Connect
QuickCart should not store passwords or build its own login screen. It delegates sign-in to an identity provider (IdP) and receives tokens. OAuth 2.0 (RFC 6749) is delegated authorization: an app gets limited API access as an access token with scopes such as orders:write. OpenID Connect (OIDC Core) adds authentication: with the scope openid the IdP also returns an ID token, a JWT saying who signed in (sub) and who issued it (iss).
QuickCart's apps use the authorization code flow with PKCE (Proof Key for Code Exchange, RFC 7636), because a mobile app cannot keep a secret and another app could steal the one-time code. The mechanism:
- The app creates a random code verifier and keeps it.
- It sends a code challenge, the base64url SHA-256 hash of the verifier, with
code_challenge_method=S256. - The IdP issues a one-time code and remembers the challenge.
- To redeem the code, the app sends the verifier. The IdP hashes and compares.
A thief with the code lacks the verifier. The request below uses the example pair from RFC 7636 Appendix B:
GET /authorize?response_type=code&client_id=quickcart-mobile&redirect_uri=com.quickcart.app%3A%2Fcallback&scope=openid%20profile&state=af0ifjsldkj&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256 HTTP/1.1
Host: idp.quickcart.example
The random state value is checked on return to stop forged redirects. At the time of writing, the OAuth 2.0 Security Best Current Practice (RFC 9700) requires PKCE for public clients, recommends it for confidential ones, says clients should not use the implicit grant and forbids the password grant. Services calling services use the client credentials grant (RFC 6749 section 4.4).
Figure 3. Signing in to QuickCart from the mobile app.
How to read this: the code crosses the phone's redirect and could be intercepted, but is useless without the verifier only the starting app holds.
JWTs, and the pitfalls of long-lived ones
A JSON Web Token (RFC 7519) is three base64url parts joined by dots: a header (algorithm, key id), a payload of claims, and a signature. A QuickCart access token payload might decode to:
{
"iss": "https://idp.quickcart.example",
"sub": "shopper_8f3a",
"aud": "quickcart-api",
"iat": 1794000000,
"exp": 1794000600,
"scope": "orders:read orders:write"
}
The payload is encoded, not encrypted, so it never carries secrets. The gateway verifies the signature with the IdP's cached public key, then the claims. RFC 8725, the JWT best current practices, is the checklist:
- Fix the allowed algorithms. An attacker can set the header to
none, or relabel an RS256 token HS256 so the public key acts as an HMAC secret (key confusion). Each key must be used with exactly one algorithm. - Validate
iss,audandexpevery time.
The main design pitfall is the long-lived JWT: nobody can recall it, so a stolen courier phone keeps working until expiry. Use a short access-token lifetime (10 minutes above, a teaching value) plus a revocable refresh token held by the IdP. RFC 9700 requires refresh tokens for public clients to be sender-constrained or rotated, so a replayed stolen token is detected. Claims also go stale: a demoted manager's token still says store_manager until it expires, so check high-risk actions at the source.
RBAC and ABAC for shoppers, couriers and stores
Role-based access control (RBAC), per the NIST RBAC model, grants permissions to roles (shopper, store_staff, courier, support_agent). It is easy to audit but cannot say which orders. Attribute-based access control (ABAC) (NIST SP 800-162) decides from attributes of subject, resource, action and context.
| Caller | RBAC says | ABAC adds |
|---|---|---|
| Shopper | May read and cancel orders | Only where order.shopperId == subject.id |
| Store staff | May update stock | Only where resource.storeId == subject.storeId (Stage 8 tenant isolation) |
| Courier | May read a delivery | Only the assigned order, until it is delivered |
| Support agent | May view and refund | Only with an open case on that order |
// Pseudocode: the object-level check, run in the Orders service on every read
function canReadOrder(subject, order) {
if (subject.role === "shopper") return order.shopperId === subject.id;
if (subject.role === "store_staff") return order.storeId === subject.storeId;
if (subject.role === "courier") {
return order.courierId === subject.id && order.status !== "DELIVERED";
}
return subject.role === "support_agent" && subject.hasOpenCaseFor(order.id);
}
Run the check in the service that owns the data, with fresh data. The gateway can reject bad tokens and coarse scopes, but it cannot know who is assigned to an order now.
Protect the data
Encryption in transit and at rest
Encryption in transit stops anyone on the path reading or altering traffic, using TLS (TLS 1.3 is RFC 8446): the server presents a certificate, the client validates it, and both derive session keys. Inside the cluster QuickCart uses mutual TLS (mTLS), where both sides present certificates, so Payments knows the caller really is Orders. A service mesh (Stage 8) can issue and rotate those certificates, a task that is error-prone by hand.
Encryption at rest protects stored bytes against a stolen disk or leaked snapshot, not against a compromised application holding a valid key. The question is where keys live. Envelope encryption, offered by the major cloud key-management services (KMS), answers it:
- A key-encryption key (KEK) lives inside the KMS and never leaves it.
- The service asks the KMS for a fresh data key and receives it in plaintext and wrapped (encrypted) under the KEK.
- It encrypts the data locally, stores ciphertext plus the wrapped key, and discards the plaintext key.
- To read, it sends the wrapped key to the KMS, which unwraps it only for an authorised caller.
Bulk encryption stays local and fast. Rotating the KEK means re-wrapping small data keys, not re-encrypting terabytes, and each unwrap is auditable. The ledger and address table get separate keys.
Secrets management
A secret is a credential a service needs: database passwords, SMS and email provider keys, signing keys.
- Not in images or repositories. An image layer keeps what was written to it even if a later step deletes the file, and Git history keeps a committed password forever. Scan commits.
- Injected at runtime from a secrets manager. Kubernetes
Secretobjects are, by default, stored unencrypted in etcd (documentation), so enable encryption at rest, restrict access with RBAC, or use an external store. - Short-lived where possible. A manager can mint a database user valid for an hour, and workload identity lets a Pod call cloud APIs with no stored key.
- Rotated and rehearsed. Accept two versions during a switch. After a leak, revoke first and investigate second.
Input validation and injection prevention
Treat every input as hostile until validated against an allow-list of what is permitted.
SQL injection pastes input into query text so that data becomes code. Store staff look up a product by name:
// VULNERABLE: input is concatenated into the SQL text
const sql = "SELECT id, name FROM products WHERE store_id = " + storeId +
" AND name = '" + name + "'";
If name is x' OR '1'='1, the database receives:
SELECT id, name FROM products WHERE store_id = 7 AND name = 'x' OR '1'='1'
AND binds tighter than OR, so the condition is (store_id = 7 AND name = 'x') OR '1'='1', true for every row: one text box has broken tenant isolation. The fix is a parameterised query: SQL text and values travel separately, so the database never parses a value as SQL.