PII Separation Architecture
Authrim implements strict separation between Personally Identifiable Information (PII) and non-PII data at both the database and application levels. This architecture enables compliance with privacy regulations (GDPR, CCPA, APPI) while maintaining high performance for authentication flows.
Why Physical DB Separation?
Unlike traditional approaches that use table-level separation, Authrim physically separates PII into a dedicated database:
- Audit & Compliance: “PII is stored in a separate DB” is more convincing for GDPR/CCPA audits than table-level separation
- Regional Deployment: Adding an EU-only PII DB requires only a new D1 binding, no schema changes
- Access Control Enforcement: Code that doesn’t have
PIIContextcannot access PII - visible in code review
Architecture Overview
flowchart TB
subgraph APP["Application Layer (Hono handlers, services, flows)"]
A1["/authorize
Core+Cache only"]
A2["/token
Core+Cache only"]
A3["/userinfo
Core+Cache+PII"]
end
subgraph REPO["Repository Layer"]
R1["CoreRepository
• UserCore
• Passkey
• Session
• Role
• Relationship
• OAuthClient"]
R2["PIIRepository
• UserProfile
• Identifiers
• LinkedIdentity
• AuditLog(PII)"]
R3["CacheRepository
• UserCache
• ConsentCache
• RBACCache
• ClientCache"]
end
subgraph ADAPTER["Database Adapter Layer"]
AD1["D1Adapter"]
AD2["DOAdapter"]
AD3["KVAdapter"]
AD4["PGAdapter
(Regional)"]
end
subgraph STORAGE["Storage"]
DB1["Global Non-PII (D1/DO)
• users_core
• passkeys
• sessions
• roles
• relationships
• oauth_clients"]
DB2["Global Cache (KV/DO)
• USER_CACHE
• REBAC_CACHE
• CONSENT_CACHE
• CLIENTS_CACHE"]
DB3["Regional PII (D1/Postgres)
EU: users_pii
JP: users_pii
US: users_pii
• identifiers
• linked_ids"]
end
APP --> REPO
REPO --> ADAPTER
AD1 --> DB1
AD2 --> DB1
AD3 --> DB2
AD4 --> DB3
Three-Layer Repository Pattern
Repositories are divided into three layers with distinct characteristics:
| Layer | Characteristics | Use Cases |
|---|---|---|
| CacheRepository | Fastest, volatile, cheap | UserInfo acceleration, RBAC claims caching |
| CoreRepository | Durable, consistent, global | Source of truth for auth/authz |
| PIIRepository | Region-bound, GDPR compliant, encrypted | Personal data only |
TypeScript-Level Access Control
TypeScript’s type system enforces PII boundaries at compile time.
Context Types
// PII-inaccessible Context (for /authorize, /token)interface AuthContext { core: CoreRepository; cache: CacheRepository; // No pii property → Cannot access PII}
// PII-accessible Context (for /userinfo, /admin/users)interface PIIContext extends AuthContext { pii: PIIRepository;}
// Handler type definitionstype AuthHandler = (c: HonoContext, ctx: AuthContext) => Promise<Response>;type PIIHandler = (c: HonoContext, ctx: PIIContext) => Promise<Response>;Compile-Time Enforcement
// authorize.ts - AuthHandler type, ctx.pii access causes compile errorexport const authorizeHandler: AuthHandler = async (c, ctx) => { const client = await ctx.cache.getCachedClient(clientId); // ✅ OK const session = await ctx.core.getSession(sessionId); // ✅ OK
// ❌ Compile Error: Property 'pii' does not exist on type 'AuthContext' // const profile = await ctx.pii.getUserProfile(userId);
return c.redirect(redirectUri);};
// userinfo.ts - PIIHandler type, ctx.pii accessibleexport const userinfoHandler: PIIHandler = async (c, ctx) => { const core = await ctx.core.getUserCore(userId); // ✅ OK const profile = await ctx.pii.getUserProfile(userId); // ✅ OK
return c.json({ sub: core.id, email: profile.email });};PII Classification
Tables Containing PII (Separation Required)
| Table | PII Fields | Risk Level |
|---|---|---|
users_pii | email, name, phone_number, address, birthdate | Critical |
user_custom_fields | field_value (arbitrary PII possible) | High |
subject_identifiers | identifier_value (email, phone, DID) | High |
linked_identities | provider_email, raw_claims, profile_data | High |
audit_log_pii | ip_address, user_agent | Medium |
Non-PII Tables (No Separation Needed)
| Category | Tables | Description |
|---|---|---|
| Auth Infrastructure | passkeys, sessions, password_reset_tokens | Public keys, session IDs (UUID references) |
| Authorization/RBAC | roles, user_roles, relationships, organizations | Role definitions, UUID references only |
| Configuration | oauth_clients, upstream_providers, scope_mappings | Client settings, IdP configurations |
Token PII Analysis
Authrim tokens are designed to be PII-free:
Access Token
const accessTokenClaims = { iss: env.ISSUER_URL, // ✅ Non-PII sub: authCodeData.sub, // ✅ UUID (Non-PII) scope: authCodeData.scope, // ✅ Non-PII authrim_roles: [...], // ✅ Non-PII authrim_org_id: "...", // ✅ UUID (Non-PII)};Result: Access Token contains no PII. sub is a UUID, email/name are only available via UserInfo endpoint.
Database Schema
Core Database (Global D1)
CREATE TABLE users_core ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL DEFAULT 'default', pii_partition TEXT NOT NULL DEFAULT 'default', pii_status TEXT NOT NULL DEFAULT 'pending', -- pending/active/failed/none/deleted email_verified INTEGER DEFAULT 0, user_type TEXT NOT NULL DEFAULT 'end_user', user_version INTEGER NOT NULL DEFAULT 1, is_deleted INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);PII Database (Regional)
CREATE TABLE users_pii ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL DEFAULT 'default', email_encrypted TEXT NOT NULL, email_blind_index TEXT NOT NULL, -- For searchable encryption name TEXT, phone_number_encrypted TEXT, address_encrypted TEXT, pii_class TEXT NOT NULL DEFAULT 'IDENTITY_CORE', anonymized_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
CREATE UNIQUE INDEX idx_users_pii_email ON users_pii(tenant_id, email_blind_index);Blind Index (Searchable Encryption)
To enable searching encrypted PII fields:
function createBlindIndex(value: string, masterIndexKey: string): string { const normalized = value.toLowerCase().trim(); return crypto.createHmac('sha256', masterIndexKey) .update(normalized) .digest('base64url');}
// Usage: Find user by email without exposing email in indexasync function findUserByEmail(email: string): Promise<string | null> { const blindIndex = createBlindIndex(email, env.INDEX_KEY); const result = await piiDb.query( 'SELECT id FROM users_pii WHERE email_blind_index = ?', [blindIndex] ); return result?.id ?? null;}Region/Partition Routing
type Partition = 'default' | 'eu' | 'jp' | 'us' | string;
class PartitionRouter { async getPIIAdapter(userId: string): Promise<DatabaseAdapter> { // Get user's partition from Core DB const user = await coreDb.queryOne<{ pii_partition: Partition }>( 'SELECT pii_partition FROM users_core WHERE id = ?', [userId] );
return this.getAdapterForPartition(user?.pii_partition || 'default'); }
getAdapterForPartition(partition: Partition): DatabaseAdapter { // Return appropriate regional database adapter switch (partition) { case 'eu': return new D1Adapter(env.DB_PII_EU); case 'jp': return new D1Adapter(env.DB_PII_JP); default: return new D1Adapter(env.DB_PII); } }}Multi-Tenant Isolation Levels
Authrim supports three levels of tenant isolation:
| Level | Description | Use Case |
|---|---|---|
| Row-level | WHERE tenant_id = ? | Default, most cost-efficient |
| Schema-level | tenant_abc.users_pii | Enterprise, separate backups |
| Database-level | Dedicated DB instance | Maximum isolation, regional compliance |
class PIIRepository { static forTenant(tenantId: string, config: TenantConfig): PIIRepository { switch (config.isolation_level) { case 'database': return new PIIRepository(getDedicatedAdapter(tenantId)); case 'schema': return new PIIRepository(sharedDb, `tenant_${tenantId}`); default: return new PIIRepository(defaultDb, 'public', tenantId); } }}Circuit Breaker & Graceful Degradation
When regional PII DB fails, authentication flow continues:
export const userinfoHandler: PIIHandler = async (c, ctx) => { // Core is required const core = await ctx.core.getUserCore(userId); if (!core) return c.json({ error: 'invalid_token' }, 401);
// PII via Circuit Breaker const profile = await piiCircuitBreaker.execute( () => ctx.pii.getUserProfile(userId), () => Promise.resolve(null) // Fallback: return null );
// Degraded response if PII unavailable return c.json({ sub: core.id, email: profile?.email ?? null, name: profile?.name ?? null, _degraded: profile === null, // Notify client of degraded state });};GDPR Compliance
Soft Delete + Anonymization
async function deleteUser(userId: string, mode: 'hard_delete' | 'anonymize') { // 1. Invalidate cache immediately await cache.invalidateUser(userId);
// 2. Process PII if (mode === 'hard_delete') { await pii.deleteUserProfile(userId); } else { await pii.anonymizeUserProfile(userId); // email → "deleted_{userId}@anonymized.local" // name, phone, address → NULL }
// 3. Soft delete in Core (keep for audit) await core.updateUserCore(userId, { is_deleted: true, deleted_at: Date.now() });
// 4. Delete relationships, sessions, passkeys await core.deleteUserRelationships(userId); await core.deleteUserSessions(userId);}Tombstone Table
CREATE TABLE users_pii_tombstone ( user_id TEXT PRIMARY KEY, email_blind_index TEXT, -- Prevent re-registration deleted_by TEXT NOT NULL, deletion_reason TEXT, deleted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), retention_until TIMESTAMPTZ NOT NULL -- When to purge);Benefits
1. Regulatory Compliance
- GDPR: Easy DSAR and right to erasure implementation
- CCPA: Clear separation for “Do Not Sell” requirements
- Data Residency: PII database can be deployed in specific regions
2. Security
- Reduced Attack Surface: Core authentication services don’t have PII access
- Principle of Least Privilege: Only PII-specific endpoints access personal data
- Audit Trail: All PII access is logged separately
3. Performance
- Smaller Core Database: Faster queries for authentication/authorization
- Caching Safety: Core data can be cached aggressively globally
- No PII in Tokens: Tokens remain small and secure
Best Practices
- Never JOIN across databases - Fetch from each database separately using
Promise.all - Use user ID as the link - The only shared field between databases
- Log all PII access - Implement audit logging for all PII operations
- Use pii_status state machine - Track PII write status (pending/active/failed/none)
- Prefer partition over region - More flexible routing (tenant, plan, security level)
- Default IP routing OFF - VPN/proxy makes IP unreliable for compliance