The page is a clue. Find out what the server actually returned.
At 10:06, a Northstar billing member opens invoice INV-4812 from a saved
workspace link. The PDF preview shows Vale’s legal name, customer email, and amount. Support
has not yet confirmed whether the browser made a fresh request or restored a previous response.
Preserve the request ID, route, authenticated account and organization IDs, response source, and invoice ownership record. Do not copy customer data or bearer tokens into a ticket. Use disposable organizations for reproduction; a guessed ID is a test input, not permission.
- Asset
- Invoice amounts, customer details, and billing actions.
- Subject
- A signed-in Northstar member, identified by server-verified session.
- Resource
- Invoice INV-4812, whose stored organization is Vale.
- Observed
- A preview displayed Vale information; server response and cache path are not yet confirmed.
- Invariant
- Each request is allowed only when this subject may perform this action on this invoice now.
Which user and organization did the server verify?
Who owns it, and what is its current state?
Permission can differ by operation.
Missing or unknown policy means deny.
Authentication establishes which identity the request represents. Authorization decides whether that identity may perform a particular action on a particular resource. Signing in to the billing API does not grant access to every invoice in its database.
One surprising preview can come from several different paths.
Before changing authorization code, write down at least two explanations. The API may have returned Vale’s invoice because it fetched by ID without checking ownership. The authenticated context may have been resolved to the wrong organization. A cache may have reused a response across sessions. Or the fixture may say Vale owns a record that belongs to Northstar.
A useful check keeps the subject, invoice ID, and action fixed while confirming the persisted owner and tracing the response source. In a controlled fixture, compare the repository query, cache hit/miss, authenticated organization, and response body for the same request. Avoid changing several conditions at once: the check should separate causes, not merely reproduce the symptom.
Compare your diagnosis with the case fileReveal after choosing a discriminating check
- Observation to establish
- A fresh API response for a Northstar principal contains the persisted Vale invoice record.
- Competing causes
- Unscoped object lookup; incorrect principal organization; cross-session cache reuse; or incorrect ownership in fixture data.
- Discriminating check
- Use a disposable Northstar principal and Vale invoice. Verify invoice ownership directly in the fixture; trace whether the response came from the API or cache; inspect the principal resolved by the server; then inspect the repository lookup and returned record.
- Conclusion if confirmed
- If a fresh server lookup returns the record while the verified principal is Northstar and persisted ownership is Vale, the request path lacks or bypasses an object-level check. That establishes this read path only; it does not prove a mutation or every route is vulnerable.
“Can access invoices” is not a complete permission rule.
An authorization decision has at least three inputs: the subject making the request, the resource it targets, and the action it asks to perform. Attributes can add conditions: organization membership, role, invoice state, or a delegation that has not expired. Make the policy legible before encoding it in handlers.
| Role | Read invoice | Download PDF | Void draft | Void issued/paid |
|---|---|---|---|---|
| Member | Allow | Deny | Deny | Deny |
| Billing | Allow | Allow | Deny | Deny |
| Owner | Allow | Allow | Allow | Deny |
| Unknown role/action | Deny | Deny | Deny | Deny |
A resource identifier selects a record. It does not authorize access.
The session tells the server who is making a request. The path parameter INV-4812 tells it which row to find. If code looks up the row by that identifier
and returns it, then a caller who can select another organization's ID can cross the boundary.
A UUID or unlisted URL may make IDs less obvious; neither carries a permission decision.
The risky pattern is easy to overlook because the handler may already have a login check. In
the example below, authentication is represented by a valid Principal; the
intentionally flawed function ignores it and fetches any invoice by ID. The sample is isolated
code, not an endpoint exposed by this site.
These isolated functions illustrate the authorization decision; they are not wired to a route.
// Intentionally flawed example: authentication is present, object authorization is absent.
export async function getInvoiceVulnerable(
repository: InvoiceRepository,
_invoicePrincipal: Principal,
invoiceId: string
): Promise<Invoice | null> {
return repository.findById(invoiceId);
} // Intentionally flawed example: the authenticated principal is ignored.
func GetInvoiceVulnerable(repo Repository, _ Principal, invoiceID string) (Invoice, bool, error) {
return repo.FindByID(invoiceID)
} The server verifies user and organization context.
The caller chooses a selector, not an authority.
Ownership and state come from trusted storage.
Permit only a matching subject/resource/action.
Scope the lookup, then decide for this action on this object.
A safer read path uses organization context resolved by the server to scope the lookup. It then checks the requested action against the loaded invoice's trusted organization and current state. This makes a cross-organization ID resolve to no visible record in this example, and makes the policy visible to tests and reviewers. Some applications intentionally use a consistent not-found response for both missing and unauthorized records; whichever response is chosen, do not return the protected object.
For a mutation, perform authorization as close as practical to the state change and make the check and update coherent under concurrent changes. For example, a void operation should re-check the actor's current organization and role and the invoice's current draft state within the transaction or an equivalent conditional update. Do not authorize once at page render and assume a later request remains permitted.
function mayPerform(principal: Principal, invoice: Invoice, action: InvoiceAction): boolean {
if (principal.organizationId !== invoice.organizationId) return false;
switch (action) {
case 'read':
return ['member', 'billing', 'owner'].includes(principal.role);
case 'download':
return ['billing', 'owner'].includes(principal.role);
case 'void':
return principal.role === 'owner' && invoice.status === 'draft';
default:
return false;
}
}
export async function getInvoice(
repository: InvoiceRepository,
principal: Principal,
invoiceId: string,
action: InvoiceAction
): Promise<Invoice | null> {
// Scope the lookup to trusted identity context, then check action on the loaded object.
const invoice = await repository.findForOrganization(invoiceId, principal.organizationId);
if (!invoice || !mayPerform(principal, invoice, action)) return null;
return invoice;
} func mayPerform(principal Principal, invoice Invoice, action Action) bool {
if principal.OrganizationID != invoice.OrganizationID {
return false
}
switch action {
case ActionRead:
return principal.Role == RoleMember || principal.Role == RoleBilling || principal.Role == RoleOwner
case ActionDownload:
return principal.Role == RoleBilling || principal.Role == RoleOwner
case ActionVoid:
return principal.Role == RoleOwner && invoice.Status == "draft"
default:
return false
}
}
func GetInvoice(repo Repository, principal Principal, invoiceID string, action Action) (Invoice, bool, error) {
// Scope the lookup using trusted identity context, then check the action on this object.
invoice, found, err := repo.FindForOrganization(invoiceID, principal.OrganizationID)
if err != nil || !found {
return Invoice{}, false, err
}
if !mayPerform(principal, invoice, action) {
return Invoice{}, false, nil
}
return invoice, true, nil
} Review the complete isolated examplesIncludes data types and repository contract
// Illustrative only: this is not an HTTP handler or a runnable endpoint.
// The caller is authenticated; invoiceId is still caller-controlled.
export type Invoice = {
id: string;
organizationId: string;
status: 'draft' | 'issued' | 'paid';
amountCents: number;
customerEmail: string;
};
export type Principal = {
userId: string;
organizationId: string;
role: 'member' | 'billing' | 'owner';
};
export type InvoiceAction = 'read' | 'download' | 'void';
export interface InvoiceRepository {
findById(id: string): Promise<Invoice | null>;
findForOrganization(id: string, organizationId: string): Promise<Invoice | null>;
}
// Intentionally flawed example: authentication is present, object authorization is absent.
export async function getInvoiceVulnerable(
repository: InvoiceRepository,
_invoicePrincipal: Principal,
invoiceId: string
): Promise<Invoice | null> {
return repository.findById(invoiceId);
}
function mayPerform(principal: Principal, invoice: Invoice, action: InvoiceAction): boolean {
if (principal.organizationId !== invoice.organizationId) return false;
switch (action) {
case 'read':
return ['member', 'billing', 'owner'].includes(principal.role);
case 'download':
return ['billing', 'owner'].includes(principal.role);
case 'void':
return principal.role === 'owner' && invoice.status === 'draft';
default:
return false;
}
}
export async function getInvoice(
repository: InvoiceRepository,
principal: Principal,
invoiceId: string,
action: InvoiceAction
): Promise<Invoice | null> {
// Scope the lookup to trusted identity context, then check action on the loaded object.
const invoice = await repository.findForOrganization(invoiceId, principal.organizationId);
if (!invoice || !mayPerform(principal, invoice, action)) return null;
return invoice;
}
package authorization
// The snippets are illustrative boundary examples, not a live HTTP endpoint.
type Role string
const (
RoleMember Role = "member"
RoleBilling Role = "billing"
RoleOwner Role = "owner"
)
type Action string
const (
ActionRead Action = "read"
ActionDownload Action = "download"
ActionVoid Action = "void"
)
type Principal struct {
UserID string
OrganizationID string
Role Role
}
type Invoice struct {
ID string
OrganizationID string
Status string
AmountCents int64
CustomerEmail string
}
type Repository interface {
FindByID(id string) (Invoice, bool, error)
FindForOrganization(id, organizationID string) (Invoice, bool, error)
}
// Intentionally flawed example: the authenticated principal is ignored.
func GetInvoiceVulnerable(repo Repository, _ Principal, invoiceID string) (Invoice, bool, error) {
return repo.FindByID(invoiceID)
}
func mayPerform(principal Principal, invoice Invoice, action Action) bool {
if principal.OrganizationID != invoice.OrganizationID {
return false
}
switch action {
case ActionRead:
return principal.Role == RoleMember || principal.Role == RoleBilling || principal.Role == RoleOwner
case ActionDownload:
return principal.Role == RoleBilling || principal.Role == RoleOwner
case ActionVoid:
return principal.Role == RoleOwner && invoice.Status == "draft"
default:
return false
}
}
func GetInvoice(repo Repository, principal Principal, invoiceID string, action Action) (Invoice, bool, error) {
// Scope the lookup using trusted identity context, then check the action on this object.
invoice, found, err := repo.FindForOrganization(invoiceID, principal.OrganizationID)
if err != nil || !found {
return Invoice{}, false, err
}
if !mayPerform(principal, invoice, action) {
return Invoice{}, false, nil
}
return invoice, true, nil
}
Security tests must keep the feature useful for the right person.
Start from the policy matrix, not from the implementation. Use two organizations with separate members and invoices. Exercise the real authorization boundary with the same invoice identifier and action while changing one relevant attribute at a time. Assert which record was returned and whether state changed; an HTTP 403 alone does not prove the right data stayed private or the intended user flow still works.
Same organization and allowed action returns the expected invoice.
Northstar cannot receive a Vale invoice by supplying its ID.
Read access does not silently grant PDF download.
Even an owner cannot void outside the allowed state.
Regression cases to keepUse the matrix to cover positive and negative behavior
- A same-organization member can read the correct invoice and sees no unrelated invoice data.
- A different organization's principal cannot read or download the invoice by changing the identifier.
- A member can read but cannot download; a billing role can read and download.
- An owner can void a draft; an owner cannot void an issued or paid invoice, and storage remains unchanged after denial.
- An unknown role, unknown action, missing policy, or missing invoice is denied without returning protected details.
- Repeat checks against alternate paths: direct PDF URL, export/API route, and any cache hit used for personalized content.
Choose a control that repairs the observed decision.
A Northstar user can open a Vale invoice by its ID. What should the team change?
You confirmed a fresh server response, a verified Northstar principal, and a persisted Vale owner for the invoice. The PDF and void endpoints are separate requests. Which next change best protects the resource?
The demonstrated path is an object-level authorization failure: the requester is allowed to use an invoice endpoint, but the decision failed to constrain access to the requested object. OWASP calls this broken object level authorization (BOLA), also commonly discussed as an insecure direct object reference when a direct identifier exposes a missing check. The identifier is not the root permission; the missing object/action decision is.
For this fixture, the confirmed consequence is disclosure of one organization's invoice details through the read path. If a separate download or mutation path also lacks an equivalent check, it could disclose a PDF or alter records; investigate those paths independently. The scenario does not establish payment execution, account takeover, or broad database access.
For further guidance, see OWASP's Authorization Cheat Sheet, API1:2023 Broken Object Level Authorization, and Insecure Direct Object Reference Prevention Cheat Sheet. Checked 2026-09-30 for deny-by-default, per-request object/action checks, and documented BOLA impacts; this lesson does not assess any specific product's policy.