Skip to content

    Forward Deployed Engineer

    Passing Identity: Authn, Authz and Tenant Isolation

    Pass a verified identity from the gateway into a Python AI service, keep authentication apart from authorisation, and scope every query to the caller's tenant.

    By Published 8 min read

    • fde
    • identity
    • authentication
    • authorization
    • tenant-isolation
    • bearer-token
    • python
    Share

    This lesson is part of Forward Deployed Engineer: Integrate and Ship. Enroll for free, then sign in, to read it in full.

    What you'll learn: how identity travels to the Python service and why the service verifies it again, authentication (who) versus authorisation (what), how the capstone's dev token works and what a real identity provider adds, and how to scope every lookup to the caller's tenant.

    At a glance

    Figure 1. Where each check happens

    The gateway authenticates the caller, then the Python service verifies the token again, checks the role and scopes retrieval to the callerClient app to API gateway (bearer token); API gateway to Python AI service (same token); Python AI service to Policy engine (may this role?); Python AI service to Retriever (identity); Retriever to Document store (filtered query)AUTHENTICATIONAUTHORISATION AND TENANT SCOPEClient app: sends a bearer tokenClient appsends a bearertokenAPI gateway: who is calling?API gatewaywho is calling?Python AI service: verifies againPython AIserviceverifies againPolicy engine: role checks in codePolicy enginerole checks incodeRetriever: filter in the queryRetrieverfilter in thequeryDocument store: partner-only markedDocument storepartner-onlymarkedbearer tokensame tokenmay this role?identityfiltered query

    How to read this: identity is set at the gateway, carried inward as a token, verified again by the service, then used by code, not the model, to allow actions and filter data.

    • Authentication answers "who is this"; authorisation answers "what may they do". They fail as 401 and 403.
    • Identity comes from a verified token, never the body, a header or the model.
    • The HMAC dev token teaches the idea; a real identity provider adds keys, claims, rotation and revocation.
    • Every lookup is scoped to the caller's tenant, and "not yours" looks like "not found".
    • The service verifies the token itself, so it stays safe if the gateway is bypassed.

    Scenario

    Composite scenario, not a real company. At Lumen Industrial Supply, the fictional distributor, the copilot serves public visitors and partners, who may see partner-only products. Two partner companies share the service. A reviewer asks: where does the service learn who the caller is, what stops partner A seeing partner B's records, and what if the gateway is bypassed? Numbers are illustrative.

    What you will be able to do

    • Explain authentication and authorisation and map each to a status code.
    • Read sign_token and verify_token and say what each line protects against.
    • State what the dev token lacks compared with a real identity provider.
    • Scope a store to the caller's tenant and write the tests that prove it.

    Prerequisites

    Metadata filters and tenant-aware access control, Permissions the application enforces, Where the Python service sits and Security and threat modeling.

    Core ideas

    Two questions, two failures

    Authentication Authorisation
    Question Who is calling? May they do this?
    Failure 401, unauthorized 403, forbidden
    Capstone code identity_from_header permissions.require
    Checked At the edge and again in the service In the service, close to the action
    Never taken from The request body or a header the caller invented The model

    RFC 6750 says an expired, revoked or malformed token (invalid_token) gets HTTP 401. A known caller refused an action gets 403.

    The dev token, line by line

    The capstone's token is an HMAC dev token, not a real identity provider. The service signs a JSON payload with a shared secret; anyone holding it can mint any identity.

    Verified: capstone/fde-product-discovery-copilot/app/security/identity.py

    def sign_token(identity: Identity, secret: str, ttl_s: int = 3600, now: float | None = None) -> str:
        exp = int((time.time() if now is None else now) + ttl_s)
        payload = json.dumps({"sub": identity.user_id, "tenant": identity.tenant, "role": identity.role, "exp": exp})
        body = _b64(payload.encode())
        return f"{body}.{_sign(body, secret)}"
    
    
    def verify_token(token: str, secret: str, now: float | None = None) -> Identity:
        body, _, signature = token.partition(".")
        if not body or not signature:
            raise AuthError("Malformed bearer token.")
        if not hmac.compare_digest(signature, _sign(body, secret)):  # constant-time compare
            raise AuthError("Invalid bearer token.")

    Two lines matter most. The signature is checked before the payload is trusted, so a forged role never reaches code that uses it. And hmac.compare_digest is designed to prevent timing analysis, says Python's documentation.

    A real identity provider adds:

    Dev token A real identity provider adds
    One shared secret signs and verifies A private key signs; services verify with a public key and cannot mint tokens
    sub, tenant, role, exp only Standard claims such as issuer and audience, so a token for another service is refused
    No revocation before exp Sessions, revocation and rotation of signing keys
    A dev endpoint hands out tokens Login, single sign-on and multi-factor, owned by the identity team

    The dev token has two dot-separated parts where a signed JWT (RFC 7519) has three, so it is not a JWT. RFC 7519 says a token must not be accepted on or after its exp; the dev token is (see the worked example).

    WARNING

    A bearer token works for anyone who holds it (RFC 6750), so it travels only over TLS. RFC 6750 says to issue short-lived tokens (one hour or less); the capstone's default is 3600 seconds.

    Propagating identity

    Figure 2. One token, verified at each hop

    sequenceDiagram accTitle: Token propagation accDescr: The client sends a request with a bearer token to the gateway. The gateway verifies it and forwards the same Authorization header to the Python service. The service verifies it again, builds an Identity object, asks the permission check whether the role may run discovery, and calls retrieval with the identity so the query is filtered. A forged or expired token is answered with 401 at the first hop that sees it. participant C as Client app participant G as API gateway participant S as Python AI service participant P as Policy engine participant R as Retriever C->>G: POST /discovery, Authorization Bearer token G->>G: verify token (401 if bad) G->>S: same Authorization header S->>S: verify token again, build Identity S->>P: may this role run discovery? P-->>S: allowed (403 if not) S->>R: search(query, identity) R-->>S: only what this identity may see

    How to read this: the same token is checked twice, then the service passes an Identity object, never the raw request, to everything that decides access.

    identity_from_header turns the Authorization header into an Identity, and the pipeline receives that object. A caller can add X-Role: partner; nothing reads it.

    Tenant isolation

    The tenant is part of the verified identity, so every query takes the identity and filters by it:

    Verified: capstone/fde-product-discovery-copilot/lessons/M5-L2/m5_l2_tenant_scope.py

        def get(self, identity: Identity, note_id: int) -> Note:
            note = self._rows.get(note_id)
            if note is None or note.tenant != identity.tenant:
                raise NotFoundError("No such note.")  # the same answer for "missing" and "someone else's"

    My default is 404, so a caller cannot learn that another tenant's record exists; I would use 403 only for a record the caller is known to share.

    NOTE

    OWASP calls this class of bug broken object-level authorisation.

    Worked example

    Assumptions: illustrative values; secret unit-test-secret; token signed at time 1,000,000 with a 60-second lifetime.

    These lines are real output of a Python run against app/security/identity.py, checking the token at times 1,000,001, 1,000,060 and 1,000,061:

    {"sub": "alice", "tenant": "acme", "role": "public", "exp": 1000060}
    64 hex characters in the signature
    Identity(user_id='alice', tenant='acme', role='public')
    1000060.0 Identity(user_id='alice', tenant='acme', role='public')
    1000061.0 AuthError Bearer token expired.

    The fourth line is the off-by-one: 1,000,060 is the exp instant, and the token still passes because verify_token rejects only when exp is less than now. RFC 7519 says it must not be accepted. Use a tested library.

    A tenant check, by counting: three notes are added (acme "a1", globex "g1", acme "a2"):

    • list(acme) returns 2 notes and list(globex) returns 1.
    • 2 + 1 = 3, and no note appears in both.

    At the customer: "what stops partner A seeing partner B's data?" A good answer names the mechanism and the test: the tenant comes from the verified token, every query takes the identity, a cross-tenant lookup returns not found, and a test fails if that breaks.

    Build it

    ./.venv/Scripts/python -m pytest lessons/M5-L2

    They check that:

    • a wrong-secret or expired token gets 401;
    • a refused role gets 403;
    • caller-added identity headers are ignored;
    • the tenant store hides other tenants' notes.

    Spot the flaw. This store authenticates its caller but has a defect a tenant would find in minutes. Find it.

    Verified: capstone/fde-product-discovery-copilot/lessons/M5-L2/m5_l2_tenant_scope.py

        def get(self, identity: Identity, note_id: int) -> Note:
            note = self._rows.get(note_id)
            if note is None:
                raise NotFoundError("No such note.")
            return note
    Answer

    The method receives the identity but never uses it. Any authenticated caller can read any note by guessing its id, which is broken object-level authorisation. Compare the note's tenant with the identity's tenant on every read and answer not found on a mismatch (test_the_leaky_store_hands_one_tenants_note_to_another shows the flaw, test_the_tenant_store_answers_not_found_for_another_tenants_note the fix).

    Decide first, then reveal.

    401, header or token? (interactive when JavaScript is on)

    A token expired an hour ago. Which status does the service return?

    • 403, because the caller is known
    • 401, because the token is no longer valid
    • 404, to hide that the route exists
    Answer

    401, because the token is no longer valid. RFC 6750 gives an expired, revoked or malformed token a 401. A 403 is for a known caller refused an action.

    A request carries a valid token for acme and the header X-Tenant: globex. Which tenant does the service use?

    • globex, because the client asked
    • Whichever the gateway adds
    • acme, from the verified token
    Answer

    acme, from the verified token. The tenant is part of the verified identity. A caller can add any header, so no header decides access.

    A token for acme leaks. With the dev token, until when can it be used?

    • Until its exp: nothing revokes it sooner
    • Until the gateway restarts
    • Until the partner logs out
    Answer

    Until its exp: nothing revokes it sooner. The dev token has no revocation before exp; a real identity provider adds it. Short lifetimes limit the damage.

    Exercise

    Add delete(identity, note_id) to the tenant store. Only partners may delete, and only in their own tenant.

    Acceptance criteria:

    • A partner deletes a note in their own tenant, and it is gone.
    • A public user in the right tenant gets ForbiddenError, and the note stays.
    • A partner of another tenant gets NotFoundError, not ForbiddenError.
    • Write the tests first.

    Done when the tests pass and you can say why the tenant is checked before the role.

    Hint 1: reuse the lookup

    Call the tenant-scoped lookup first and let its error pass through.

    Hint 2: order of the checks

    If the role check came first, a public user from another tenant would see 403 for a real note and 404 for a missing one, and so learn which ids exist.

    Common mistakes

    • Reading the tenant from the request body. The caller controls the body. Use the verified token.
    • Trusting a header the gateway "should" add. A caller can set it if the service is reachable.
    • Checking authorisation only in the interface. Hiding a button is not a check.
    • Asking the model whether an action is allowed. The model can be persuaded; code cannot.

    Knowledge check

    1. Why does the Python service verify the token again when the gateway already did?

    The service may be reached by another route, a misconfigured rule or an internal caller. Verifying again costs one hash comparison.

    2. A valid token asks for an action its role does not allow. Which status, and why?
    1. The caller is known, and authorisation refused the action. A 401 would tell the client to log in again, which would not help.
    3. Why does a cross-tenant lookup return "not found" and not "forbidden"?

    A forbidden answer confirms the record exists, so with sequential ids a caller could map another tenant's data. Not found for both cases gives nothing away.

    4. Name two things a real identity provider gives you that the dev token does not.

    Public-key signing, so services can verify tokens but cannot mint them, and revocation or key rotation. Standard claims such as audience are a third.

    5. What does the one-second acceptance at `exp` teach?

    Hand-written security checks hide small errors that a mature library has fixed. RFC 7519 says a token must not be accepted on or after its expiry; the dev token accepts it at exactly that second.

    You can now

    • Map authentication and authorisation to 401 and 403 and to the capstone's functions.
    • Explain how the dev token is built and what a real identity provider adds.
    • Describe how identity propagates from the client to the retrieval query.
    • Scope a store to a tenant and write the tests that prove the isolation.

    Further reading

    Last verified: 2026-11-02

    Next

    Next lesson: Timeouts, retries, idempotency; sync or async

    Enroll to keep reading

    Lesson 2 of 5 · Forward Deployed Engineer: Integrate and Ship

    Pass a verified identity from the gateway into a Python AI service, keep authentication apart from authorisation, and scope every query to the caller's tenant.

    In the rest of this lesson

    • Worked example
    • Build it
    • Exercise
    • Common mistakes
    • Knowledge check
    • You can now

    Enrolling is free. It unlocks every lesson in Forward Deployed Engineer: Integrate and Ship, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments