An identifier is not an access policy
A UUID identifies a tenant or resource. It does not prove who owns that resource or who may access it. Even a difficult-to-guess UUID can be copied from a link or log. Every request still needs authorization, as the OWASP IDOR prevention guide explains.
In a shared database, tenant IDs give records a scope. Separate schemas or databases can provide additional boundaries, but the application must still route each authenticated request to the correct tenant.
Choose the version for the workload
UUID v4 is a useful choice when an identifier should not encode a timestamp. Python's uuid.uuid4() uses cryptographically secure randomness; it does not use the ordinary random module's getrandbits function. See the Python UUID documentation.
import uuid
tenant_id = uuid.uuid4()
document_id = uuid.uuid4()
assert tenant_id.version == 4
assert document_id.version == 4UUID v7 is standardized by RFC 9562 and includes a timestamp, which can help index locality but exposes approximate generation time. Version 1 also contains time and a node field that may derive from a MAC address. Choose deliberately when exposing identifiers publicly. The layouts are defined in RFC 9562.
Keep uniqueness constraints even when collision probability is very small. Treat an identifier as public metadata, not as a password or bearer token.
Model tenant-scoped relationships
This PostgreSQL example keeps tenants, documents, and comments in shared tables:
CREATE TABLE tenants (
tenant_id uuid PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE documents (
tenant_id uuid NOT NULL REFERENCES tenants (tenant_id),
document_id uuid NOT NULL,
title text NOT NULL,
PRIMARY KEY (tenant_id, document_id)
);
CREATE TABLE comments (
tenant_id uuid NOT NULL,
comment_id uuid NOT NULL,
document_id uuid NOT NULL,
body text NOT NULL,
PRIMARY KEY (tenant_id, comment_id),
FOREIGN KEY (tenant_id, document_id)
REFERENCES documents (tenant_id, document_id)
);
CREATE INDEX comments_document_idx
ON comments (tenant_id, document_id);The composite foreign key requires the referenced document to exist in the comment's tenant. The supporting index helps lookups by document and checks when a parent changes. A foreign key on document_id alone would lose that explicit tenant relationship. PostgreSQL describes these rules in its constraint documentation.
These keys do not filter a SELECT statement. A role with unrestricted read access can still read other tenants' rows. The schema also does not enforce global uniqueness of document_id by itself; its uniqueness is scoped to tenant_id.
Derive tenant scope from verified identity
After authenticating a request, the server must verify that the caller belongs to the selected tenant and has the required operation-level permissions. Do not trust a tenant ID supplied in a URL, header, or form without that check.
Use bound parameters for the subsequent query:
SELECT document_id, title
FROM documents
WHERE tenant_id = $1 AND document_id = $2;Here $1 is the tenant ID already authorized by the server and $2 is the requested document ID. Parameter binding prevents values from becoming SQL syntax; it does not decide whether the caller has permission. Apply the same scope to writes, exports, background jobs, and cache keys.
Add database enforcement where appropriate
PostgreSQL row-level security can enforce row policies in addition to application checks. Once enabled, a table with no applicable policy denies normal row access. Table owners normally bypass RLS unless FORCE ROW LEVEL SECURITY is used; superusers and BYPASSRLS roles still bypass it. Run application requests with a suitably restricted role and test the policies under that role. See PostgreSQL row security.
If a policy depends on session tenant context, the trusted application must set it from verified membership. A client able to choose that context can choose another tenant. Connection pooling also requires careful transaction scoping so one request's tenant context cannot carry into another request.
RLS configuration depends on your authentication and connection model. The schema above intentionally demonstrates keys and relationships only; it is not a complete access-control implementation.
Test isolation with two tenants
Create tenants A and B, with a document in each. Authenticate as a user who belongs only to A, then try B's actual document UUID. Verify that reads, edits, deletes, exports, and comment creation are denied without disclosing B's data. Test real identifiers, not only nonexistent ones.
Separately, attempt to insert a comment scoped to A that references a document existing only in B. The composite foreign key should reject it. Then confirm that a correctly scoped comment succeeds. This tests relationship integrity independently of request authorization.
Also test a revoked membership and consecutive requests for different tenants through the same connection pool. Cover background jobs and administrative paths explicitly; they often use different credentials or query code.
Keep identifiers and routing understandable
Store tenant_id and resource ID as separate fields. A display prefix such as org_ can help humans recognize an identifier, but it is not an access-control mechanism and makes the full string something other than a standard UUID.
For distributed systems, decide whether records route by tenant or resource ID. The sharding guide explains that trade-off. Use native or binary UUID storage consistently, and the UUID validator to inspect example identifiers.
