Principle of Least Privilege #

Imagine a cashier in a store. They need access to the cash register to process transactions — but they don’t need the safe key, they don’t need access to the server room, and they don’t need to know the safe deposit combination. Giving them all that access doesn’t make them more productive — quite the opposite: every extra access is an additional risk if their account is compromised or if they make a mistake. The same principle applies in software: every component, service, user, or process may only have the minimum access truly required to perform its function — nothing more. The Principle of Least Privilege (PoLP) is a foundation of modern security engineering. It’s not only about preventing external attacks — it’s also about limiting the impact when something goes wrong, whether from bugs, misconfiguration, leaked credentials, or insider threats. This article covers PoLP from the most concrete levels — database user permissions, IAM policies, API token scopes — to system architecture levels with RBAC and zero trust, complete with Go and Dart implementations you can apply directly.

Why Least Privilege? #

PoLP attacks two problems at once: blast radius and attack surface.

Blast radius is how much damage occurs when something fails or is compromised. Without PoLP, one leaked credential can provide access to the entire system. With PoLP, the same credential only provides access to a small subset of the system — damage is isolated.

Attack surface is the number of points that can be attacked. Every extra permission granted is one more potential attack point. Reducing permissions means directly reducing the attack surface.

Without PoLP:                         With PoLP:
──────────────────────────────      ──────────────────────────────────
App DB user has:                    App DB user has:
  SELECT, INSERT, UPDATE,             SELECT, INSERT, UPDATE, DELETE
  DELETE, DROP, CREATE TABLE,         only on the tables it uses
  ALTER, GRANT, TRUNCATE              (no DROP, CREATE, GRANT)

If the credential leaks:            If the credential leaks:
  Attacker can drop every table      Attacker can read/write data
  Can create new users               Can't drop tables
  Can exfiltrate all data            Can't change the schema
  Blast radius: TOTAL                Blast radius: LIMITED
flowchart TD
    BREACH["Credential/Token\nCompromised"]

    subgraph NO_POLP["Without PoLP"]
        A1["Access to ALL resources"]
        A2["DROP every table"]
        A3["Create a new admin user"]
        A4["Exfiltrate all data"]
        A5["💥 Total compromise"]
        A1 --> A2 & A3 & A4 --> A5
    end

    subgraph WITH_POLP["With PoLP"]
        B1["LIMITED access\nto needed resources"]
        B2["Read/write only\nauthorized data"]
        B3["⚠ Partial compromise\nLimited blast radius"]
        B1 --> B2 --> B3
    end

    BREACH --> NO_POLP
    BREACH --> WITH_POLP

    style A5 fill:#D9534F,color:#fff
    style B3 fill:#F0AD4E,color:#fff

Level 1 — PoLP in Databases #

The database is the first place most directly affected if PoLP is ignored. One common mistake: using a database superuser or a user with all privileges for the application connection.

-- ANTI-PATTERN: the app uses a superuser or a user with excessive rights
-- If this credential leaks → the attacker can do anything to the database

-- Don't do this:
CREATE USER app_user WITH PASSWORD 'secret' SUPERUSER;
-- or
GRANT ALL PRIVILEGES ON DATABASE myapp TO app_user;

-- CORRECT: create separate users per use case, with minimal privileges

-- 1. User for the main app (data read + write)
CREATE USER app_writer WITH PASSWORD 'strong_random_password_here';
GRANT CONNECT ON DATABASE myapp TO app_writer;
GRANT USAGE ON SCHEMA public TO app_writer;
-- Only the tables needed, only the operations needed
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE
    users, orders, order_items, products, sessions
    TO app_writer;
-- For sequences (auto increment IDs)
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_writer;

-- 2. Read-only user (reporting, analytics, read-only background jobs)
CREATE USER app_reader WITH PASSWORD 'another_strong_password';
GRANT CONNECT ON DATABASE myapp TO app_reader;
GRANT USAGE ON SCHEMA public TO app_reader;
GRANT SELECT ON TABLE
    users, orders, order_items, products
    TO app_reader;
-- Can't INSERT, UPDATE, DELETE, DROP — not at all

-- 3. User for migrations (only used during deploys, not at runtime)
CREATE USER app_migrator WITH PASSWORD 'migration_password';
GRANT CONNECT ON DATABASE myapp TO app_migrator;
GRANT USAGE, CREATE ON SCHEMA public TO app_migrator;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO app_migrator;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO app_migrator;
-- This user is only used by the migration tool, not by the runtime app
-- Its credential is rotated after every deployment

-- 4. Make sure default privileges for new tables are also limited
ALTER DEFAULT PRIVILEGES IN SCHEMA public
    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_writer;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
    GRANT SELECT ON TABLES TO app_reader;

Go implementation — using the connection matching the operation:

// Separate connections by the privileges required
type Database struct {
    writer   *sql.DB // app_writer — for write operations
    reader   *sql.DB // app_reader — for read operations
}

func NewDatabase(writerDSN, readerDSN string) (*Database, error) {
    writer, err := sql.Open("postgres", writerDSN)
    if err != nil {
        return nil, fmt.Errorf("open writer connection: %w", err)
    }

    reader, err := sql.Open("postgres", readerDSN)
    if err != nil {
        return nil, fmt.Errorf("open reader connection: %w", err)
    }

    return &Database{writer: writer, reader: reader}, nil
}

type OrderRepository struct {
    db *Database
}

// Write operations → use the writer connection
func (r *OrderRepository) Save(ctx context.Context, order Order) error {
    _, err := r.db.writer.ExecContext(ctx,
        "INSERT INTO orders (id, user_id, total, status) VALUES ($1, $2, $3, $4)",
        order.ID, order.UserID, order.Total, order.Status,
    )
    if err != nil {
        return fmt.Errorf("save order: %w", err)
    }
    return nil
}

// Read operations → use the reader connection (least privilege)
func (r *OrderRepository) ListByUserID(ctx context.Context, userID string) ([]Order, error) {
    rows, err := r.db.reader.QueryContext(ctx,
        "SELECT id, user_id, total, status, created_at FROM orders WHERE user_id = $1",
        userID,
    )
    if err != nil {
        return nil, fmt.Errorf("list orders: %w", err)
    }
    defer rows.Close()
    // ... scan rows
    return orders, nil
}

Level 2 — PoLP in Cloud IAM #

In cloud environments (GCP, AWS, Azure), IAM (Identity and Access Management) is the most critical PoLP implementation. Service accounts or roles with excessive permissions are a risk often underestimated until an incident happens.

ANTI-PATTERN: service accounts with overly broad permissions

# GCP: a service account with the Editor role — access to nearly all resources
gcloud projects add-iam-policy-binding my-project \
    --member="serviceAccount:[email protected]" \
    --role="roles/editor"
# If this SA is compromised: the attacker can create/delete resources,
# access all storage buckets, read all secrets, etc.

# AWS: an IAM role with AdministratorAccess
{
    "Effect": "Allow",
    "Action": "*",
    "Resource": "*"
}
# Wildcards in Action and Resource = least privilege completely ignored

CORRECT: specific permissions matching the service's actual needs

# GCP: a service account for the Order Service
# Only needs: read/write to specific Cloud Pub/Sub topics,
#             read specific secrets, access specific Firestore collections

# Create a custom role with minimal permissions
gcloud iam roles create order_service_role \
    --project=my-project \
    --permissions="pubsub.topics.publish,pubsub.subscriptions.consume,\
                   secretmanager.versions.access,\
                   datastore.entities.create,datastore.entities.get,\
                   datastore.entities.update,datastore.entities.list"

# Bind to the specific service account for the order service
gcloud projects add-iam-policy-binding my-project \
    --member="serviceAccount:[email protected]" \
    --role="projects/my-project/roles/order_service_role"
// In Go, use workload identity or a preconfigured service account
// Don't hardcode credentials in code — let the platform inject them automatically

// ANTI-PATTERN: hardcoded credentials or overly broad environment variables
func newStorageClient() (*storage.Client, error) {
    // A JSON key file on disk = a stealable credential, never auto-rotated
    return storage.NewClient(ctx,
        option.WithCredentialsFile("/path/to/service-account-key.json"),
    )
}

// CORRECT: Application Default Credentials — the platform injects identity automatically
// On GKE: Workload Identity → pods automatically get the SA identity
// On Cloud Run: the service account is bound at deploy time
// On VMs: the metadata server provides tokens automatically
func newStorageClient(ctx context.Context) (*storage.Client, error) {
    // No explicit credential — use ADC
    // The platform ensures this service only has the configured access
    client, err := storage.NewClient(ctx)
    if err != nil {
        return nil, fmt.Errorf("create storage client: %w", err)
    }
    return client, nil
}

// Access only the specific bucket — not all buckets
func (s *FileService) UploadOrderDocument(
    ctx context.Context,
    orderID string,
    data []byte,
) error {
    bucket := s.storage.Bucket(s.orderDocumentBucket) // specific bucket
    obj := bucket.Object(fmt.Sprintf("orders/%s/document.pdf", orderID))

    writer := obj.NewWriter(ctx)
    if _, err := writer.Write(data); err != nil {
        return fmt.Errorf("upload document %s: write: %w", orderID, err)
    }
    if err := writer.Close(); err != nil {
        return fmt.Errorf("upload document %s: close: %w", orderID, err)
    }
    return nil
    // No access to other buckets — PoLP at the code level
}

Level 3 — PoLP in API Tokens and OAuth Scopes #

API tokens and OAuth scopes are the PoLP implementation for external access. A token with access to every endpoint is a risk as big as a password reused everywhere.

// ANTI-PATTERN: one API key for all operations
// If this token leaks → full access to every feature

const apiKey = "sk_live_example_never_use_this_in_production"
// Used for: read users, write users, read orders, write orders,
//           read reports, send notifications, admin panel access

// CORRECT: tokens with scopes limited to the caller's needs

// Defining the available scopes
type Scope string

const (
    ScopeOrderRead    Scope = "order:read"
    ScopeOrderWrite   Scope = "order:write"
    ScopeUserRead     Scope = "user:read"
    ScopeUserWrite    Scope = "user:write"
    ScopeReportRead   Scope = "report:read"
    ScopeAdminAccess  Scope = "admin:*"
)

// A token store with per-token scopes
type APIToken struct {
    ID        string
    Hash      string    // bcrypt hash of the token value
    OwnerID   string
    Scopes    []Scope
    ExpiresAt time.Time
    CreatedAt time.Time
    LastUsedAt *time.Time
}

// Tokens issued matching client needs:
// Mobile app → [order:read, order:write, user:read]
// Analytics dashboard → [order:read, report:read]
// Admin panel → [admin:*]
// Webhook receiver → [order:read] — only needs read for verification

// Middleware that enforces scopes
func RequireScope(required Scope) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            token, ok := tokenFromContext(r.Context())
            if !ok {
                http.Error(w, "unauthorized", http.StatusUnauthorized)
                return
            }

            if !token.HasScope(required) {
                // Log access attempts without the right scope — could be an anomaly
                slog.Warn("scope violation attempt",
                    "token_id", token.ID,
                    "required_scope", required,
                    "token_scopes", token.Scopes,
                    "path", r.URL.Path,
                )
                http.Error(w, "insufficient scope", http.StatusForbidden)
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

func (t APIToken) HasScope(required Scope) bool {
    for _, s := range t.Scopes {
        if s == required || s == ScopeAdminAccess {
            return true
        }
        // Wildcard scope matching: "order:*" covers "order:read" and "order:write"
        if strings.HasSuffix(string(s), ":*") {
            prefix := strings.TrimSuffix(string(s), ":*")
            if strings.HasPrefix(string(required), prefix+":") {
                return true
            }
        }
    }
    return false
}

// Routing with scope enforcement
router.Get("/api/v1/orders", RequireScope(ScopeOrderRead)(listOrdersHandler))
router.Post("/api/v1/orders", RequireScope(ScopeOrderWrite)(createOrderHandler))
router.Get("/api/v1/reports/revenue", RequireScope(ScopeReportRead)(revenueReportHandler))
router.Get("/api/v1/admin/users", RequireScope(ScopeAdminAccess)(adminUserListHandler))

Level 4 — PoLP in Application RBAC #

Role-Based Access Control (RBAC) is the PoLP implementation at the application level for users. Every user has a role, every role has defined permissions — and nothing more.

// Defining roles and permissions in the domain
type Role string
type Permission string

const (
    RoleCustomer Role = "customer"
    RoleMerchant Role = "merchant"
    RoleSupport  Role = "support"
    RoleAdmin    Role = "admin"
)

const (
    PermViewOwnOrders    Permission = "order:view:own"
    PermViewAllOrders    Permission = "order:view:all"
    PermCreateOrder      Permission = "order:create"
    PermCancelOwnOrder   Permission = "order:cancel:own"
    PermCancelAnyOrder   Permission = "order:cancel:any"
    PermViewProducts     Permission = "product:view"
    PermManageProducts   Permission = "product:manage"
    PermViewUsers        Permission = "user:view"
    PermManageUsers      Permission = "user:manage"
    PermViewReports      Permission = "report:view"
    PermRefundOrder      Permission = "order:refund"
)

// Permission matrix — the SSOT for per-role access definitions
var rolePermissions = map[Role][]Permission{
    RoleCustomer: {
        PermViewOwnOrders,
        PermCreateOrder,
        PermCancelOwnOrder,
        PermViewProducts,
    },
    RoleMerchant: {
        PermViewOwnOrders,
        PermViewAllOrders,  // merchants can see all orders in their store
        PermManageProducts,
        PermViewReports,
    },
    RoleSupport: {
        PermViewAllOrders,
        PermCancelAnyOrder,
        PermRefundOrder,
        PermViewUsers,
        PermViewProducts,
    },
    RoleAdmin: {
        PermViewAllOrders,
        PermCancelAnyOrder,
        PermRefundOrder,
        PermViewUsers,
        PermManageUsers,
        PermManageProducts,
        PermViewReports,
    },
    // Admin doesn't implicitly get every permission —
    // it has explicitly defined permissions
    // This matters: if a new permission is added, admin doesn't
    // automatically get it until explicitly added — an intentional security decision
}

type Authorizer struct {
    rolePerms map[Role][]Permission
}

func NewAuthorizer() *Authorizer {
    return &Authorizer{rolePerms: rolePermissions}
}

func (a *Authorizer) Can(role Role, perm Permission) bool {
    perms, ok := a.rolePerms[role]
    if !ok {
        return false
    }
    for _, p := range perms {
        if p == perm {
            return true
        }
    }
    return false
}

// Authorization middleware with resource ownership checks
func (a *Authorizer) RequirePermission(perm Permission) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            user, ok := userFromContext(r.Context())
            if !ok {
                http.Error(w, "unauthorized", http.StatusUnauthorized)
                return
            }

            if !a.Can(user.Role, perm) {
                slog.Warn("authorization denied",
                    "user_id", user.ID,
                    "role", user.Role,
                    "permission", perm,
                    "path", r.URL.Path,
                )
                http.Error(w, "forbidden", http.StatusForbidden)
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

// Ownership check — customers can only access their own resources
func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
    currentUser := userFromContext(r.Context())
    orderID := r.PathValue("id")

    order, err := h.service.GetOrder(r.Context(), orderID)
    if err != nil {
        if errors.Is(err, order.ErrNotFound) {
            http.Error(w, "order not found", http.StatusNotFound)
        } else {
            http.Error(w, "internal error", http.StatusInternalServerError)
        }
        return
    }

    // Ownership check: customers may only view their own orders
    // Support and Admin may view all (already checked in middleware)
    if currentUser.Role == RoleCustomer && order.UserID != currentUser.ID {
        // Don't expose that the order exists — return 404 not 403
        // to avoid information leakage
        http.Error(w, "order not found", http.StatusNotFound)
        return
    }

    writeSuccess(w, http.StatusOK, order)
}

// Router setup with RBAC
auth := NewAuthorizer()

router.Get("/api/v1/orders",
    auth.RequirePermission(PermViewAllOrders)(listAllOrdersHandler))
router.Get("/api/v1/orders/my",
    auth.RequirePermission(PermViewOwnOrders)(listMyOrdersHandler))
router.Post("/api/v1/orders",
    auth.RequirePermission(PermCreateOrder)(createOrderHandler))
router.Post("/api/v1/orders/{id}/refund",
    auth.RequirePermission(PermRefundOrder)(refundOrderHandler))
router.Get("/api/v1/admin/users",
    auth.RequirePermission(PermManageUsers)(adminUserListHandler))

Level 5 — PoLP in Secret Management #

Secrets (API keys, passwords, certificates) are the PoLP implementation often ignored until a leak happens. Secrets stored in the wrong place, with overly broad access, are the PoLP violations that most often end up as security incidents.

// ANTI-PATTERN: secrets in environment variables readable by every process
// or in config files committed to the repository

// ✗ Hardcoded secrets
const paymentAPIKey = "sk_live_example_never_use_this_in_production" // in git history forever

// ✗ Secrets in env vars without encryption or rotation
// DATABASE_URL=postgres://user:***@host/db
// copied to all servers without an audit trail

// ✗ One secret for every environment
// The same key for development, staging, and production

// CORRECT: secret management following PoLP

// Each service can only access its own secrets
// GCP Secret Manager with per-secret IAM bindings:
//
// secret: payment-api-key
//   accessor: [email protected] (roles/secretmanager.secretAccessor)
//
// secret: database-dsn
//   accessor: [email protected] (roles/secretmanager.secretAccessor)
//
// secret: smtp-password
//   accessor: [email protected]
//
// The Order Service CANNOT access smtp-password — no IAM binding

type SecretManager struct {
    client    *secretmanager.Client
    projectID string
}

func (sm *SecretManager) GetSecret(ctx context.Context, name string) (string, error) {
    // Format: projects/{project}/secrets/{secret}/versions/latest
    secretName := fmt.Sprintf("projects/%s/secrets/%s/versions/latest", sm.projectID, name)

    result, err := sm.client.AccessSecretVersion(ctx,
        &secretmanagerpb.AccessSecretVersionRequest{Name: secretName},
    )
    if err != nil {
        return "", fmt.Errorf("get secret %q: %w", name, err)
    }

    return string(result.Payload.Data), nil
}

// Startup: fetch all required secrets once
func initSecrets(ctx context.Context, sm *SecretManager) (*Secrets, error) {
    paymentKey, err := sm.GetSecret(ctx, "payment-api-key")
    if err != nil {
        return nil, fmt.Errorf("init secrets: payment key: %w", err)
    }

    dbDSN, err := sm.GetSecret(ctx, "database-dsn")
    if err != nil {
        return nil, fmt.Errorf("init secrets: database dsn: %w", err)
    }

    return &Secrets{
        PaymentAPIKey: paymentKey,
        DatabaseDSN:   dbDSN,
    }, nil
}

// Secrets are never logged or exposed in responses
type Secrets struct {
    PaymentAPIKey string
    DatabaseDSN   string
}

// A safe Stringer — won't expose secrets if accidentally printed
func (s Secrets) String() string {
    return "Secrets{PaymentAPIKey: *** DatabaseDSN: [REDACTED]}"
}

Level 6 — PoLP in Service-to-Service Communication #

In microservices architecture, every service calling another service must be authenticated and authorized. Service A must not be able to call endpoints meant only for Service B — even if both are on the same network.

// ANTI-PATTERN: service communication without authentication
// Every service on the internal network can call every other service
// "If it's already inside the network, it must be safe"

func callUserService(userID string) (*User, error) {
    resp, err := http.Get("http://user-service/users/" + userID)
    // No authentication — anyone on the network can call this
    if err != nil {
        return nil, err
    }
    // ...
}

// CORRECT: mutual TLS or service tokens for service-to-service auth

// Approach 1: JWT service tokens with audience claims
type ServiceToken struct {
    Issuer   string   // "order-service"
    Audience string   // "user-service" — the token is only valid for this service
    Scopes   []string // ["user:read"] — the minimal permissions needed
    IssuedAt time.Time
    Expiry   time.Time
}

// The Order Service calls the User Service with a specific token
type UserServiceClient struct {
    baseURL    string
    httpClient *http.Client
    tokenSrc   TokenSource // gets tokens for calling user-service
}

func (c *UserServiceClient) GetUser(ctx context.Context, userID string) (*User, error) {
    // Get a token with audience "user-service" and minimal scopes
    token, err := c.tokenSrc.Token(ctx, TokenRequest{
        Audience: "user-service",
        Scopes:   []string{"user:read"}, // read only, no write needed
    })
    if err != nil {
        return nil, fmt.Errorf("get service token: %w", err)
    }

    req, err := http.NewRequestWithContext(ctx,
        http.MethodGet,
        fmt.Sprintf("%s/internal/users/%s", c.baseURL, userID),
        nil,
    )
    if err != nil {
        return nil, err
    }

    req.Header.Set("Authorization", "Bearer "+token.Value)
    req.Header.Set("X-Service-Name", "order-service") // for audit logs

    resp, err := c.httpClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("call user service: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode == http.StatusForbidden {
        return nil, ErrServiceNotAuthorized
    }
    // ...
}

// The User Service validates that the caller is an allowed service
func ServiceAuthMiddleware(allowedServices []string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            token := extractBearerToken(r)
            claims, err := validateServiceToken(token)
            if err != nil {
                http.Error(w, "unauthorized", http.StatusUnauthorized)
                return
            }

            // Check whether the issuer (calling service) is allowed
            allowed := false
            for _, svc := range allowedServices {
                if claims.Issuer == svc {
                    allowed = true
                    break
                }
            }

            if !allowed {
                slog.Warn("unauthorized service call",
                    "issuer", claims.Issuer,
                    "allowed", allowedServices,
                    "path", r.URL.Path,
                )
                http.Error(w, "forbidden", http.StatusForbidden)
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

// Internal endpoints are only for service-to-service
router.Get("/internal/users/{id}",
    ServiceAuthMiddleware([]string{"order-service", "notification-service"})(
        internalGetUserHandler,
    ),
)

PoLP and Audit Trails #

Least privilege can’t be enforced without auditing. You need to know who accesses what, when, and whether it matches what should be happening.

// Audit middleware — every access to sensitive resources is recorded
type AuditEvent struct {
    Timestamp  time.Time `json:"timestamp"`
    ActorID    string    `json:"actor_id"`
    ActorRole  string    `json:"actor_role"`
    Action     string    `json:"action"`
    Resource   string    `json:"resource"`
    ResourceID string    `json:"resource_id"`
    Allowed    bool      `json:"allowed"`
    IPAddress  string    `json:"ip_address"`
    TraceID    string    `json:"trace_id"`
}

type AuditLogger struct {
    logger *slog.Logger
}

func (a *AuditLogger) Log(ctx context.Context, event AuditEvent) {
    event.Timestamp = time.Now()
    a.logger.InfoContext(ctx, "audit",
        "actor_id", event.ActorID,
        "actor_role", event.ActorRole,
        "action", event.Action,
        "resource", event.Resource,
        "resource_id", event.ResourceID,
        "allowed", event.Allowed,
        "ip_address", event.IPAddress,
        "trace_id", event.TraceID,
    )
}

func AuditMiddleware(audit *AuditLogger, action, resource string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            user, _ := userFromContext(r.Context())

            rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
            next.ServeHTTP(rec, r)

            audit.Log(r.Context(), AuditEvent{
                ActorID:    user.ID,
                ActorRole:  string(user.Role),
                Action:     action,
                Resource:   resource,
                ResourceID: r.PathValue("id"),
                Allowed:    rec.status != http.StatusForbidden,
                IPAddress:  r.RemoteAddr,
                TraceID:    traceIDFromContext(r.Context()),
            })
        })
    }
}

// Sensitive endpoints with audit
router.Post("/api/v1/orders/{id}/refund",
    auth.RequirePermission(PermRefundOrder)(
        AuditMiddleware(auditLogger, "refund", "order")(
            refundHandler,
        ),
    ),
)

PoLP in Dart/Flutter #

In mobile apps, PoLP applies to the device permissions requested and to the tokens stored on the device.

// ANTI-PATTERN: requesting every permission when the app first opens
class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // Request all permissions at once — the user doesn't know why
        requestPermissions(new String[]{
                Manifest.permission.CAMERA,
                Manifest.permission.RECORD_AUDIO,
                Manifest.permission.ACCESS_FINE_LOCATION,
                Manifest.permission.READ_CONTACTS,
                Manifest.permission.READ_EXTERNAL_STORAGE,
                Manifest.permission.POST_NOTIFICATIONS,
        }, REQUEST_CODE_ALL_PERMISSIONS);
        // The user is immediately suspicious: "Why does a shopping app need my microphone?"
    }
}

// CORRECT: request permissions only when truly needed,
// with contextual explanation

class OrderService {
    private final Activity activity;

    OrderService(Activity activity) {
        this.activity = activity;
    }

    // Camera permission only requested when the user wants to scan a barcode
    void scanBarcode() {
        if (ContextCompat.checkSelfPermission(activity, Manifest.permission.CAMERA)
                != PackageManager.PERMISSION_GRANTED) {
            // Explain why before requesting
            showPermissionRationale(
                    "Camera Access",
                    "The camera is needed to scan product barcodes.",
                    this::requestCameraPermission);
        } else {
            // Only scan the barcode after permission is granted
            doScanBarcode();
        }
    }

    private void requestCameraPermission() {
        // Pseudo-callback: invoked when the OS resolves the permission dialog
        onCameraPermissionResult(granted -> {
            if (!granted) {
                throw new SecurityException(
                        "Camera permission is required for barcode scanning");
            }
            // Only scan the barcode after permission is granted
            doScanBarcode();
        });
        activity.requestPermissions(new String[]{Manifest.permission.CAMERA}, REQUEST_CAMERA);
    }
}

// ANTI-PATTERN: storing an access token with no expiry in local storage
// getSharedPreferences("auth", MODE_PRIVATE).edit()
//     .putString("auth_token", fullAccessToken).apply()
// The token never expires, stored as plain text

// CORRECT: tokens with minimal scopes, short expiry, and secure storage
class TokenStorage {
    // Encrypted on the Android Keystore via EncryptedSharedPreferences
    private final SharedPreferences prefs;

    TokenStorage(Context context) {
        MasterKey masterKey = new MasterKey.Builder(context)
                .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
                .build();
        prefs = EncryptedSharedPreferences.create(
                context,
                "secure_prefs",
                masterKey,
                EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
                EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM);
    }

    // Store the access token (short-lived) and refresh token (long-lived)
    // separately, with automatic refresh mechanisms
    void saveTokens(String accessToken, String refreshToken) {
        prefs.edit()
                .putString("access_token", accessToken)   // expires in 15 minutes
                .putString("refresh_token", refreshToken) // expires in 30 days
                .apply();
    }

    String getAccessToken() {
        return prefs.getString("access_token", null);
    }

    void clearTokens() {
        prefs.edit()
                .remove("access_token")
                .remove("refresh_token")
                .apply();
    }
}

When PoLP Becomes Excessive #

PoLP has a point of diminishing returns — being too granular just creates overhead disproportionate to the security benefit.

TOO GRANULAR (over-engineering PoLP):
  ✗ Creating a separate role for every possible permission combination
     → 50 roles with complicated overlaps, harder to audit than to secure
  ✗ Per-field database permissions (SELECT only certain columns)
     for non-sensitive data
  ✗ Per-endpoint tokens for internal services that already have mTLS
  ✗ Hourly secret rotation for secrets never exposed externally
     with no compromise indicators

PROPORTIONAL TO RISK:
  Highly sensitive data (credit cards, health, identity)
  → As granular as possible, full audit, strict rotation

  Regular business data (orders, products, reviews)
  → Sensible role-based access, audit for critical operations

  Public or low-risk data
  → Authentication is enough, minimal authorization

QUESTIONS FOR DETERMINING GRANULARITY:
  "If this access is misused, how big is the impact?"
  "How often does this permission need to change?"
  "Does a more detailed audit trail provide real value?"
PoLP without auditing is the same as no PoLP. Permissions configured correctly but never reviewed can drift over time — a permission originally needed for a feature can remain after the feature is removed. Schedule periodic permission reviews: quarterly for critical permissions, yearly for regular ones. Use tools like AWS IAM Access Analyzer or GCP Recommender for automatic recommendations about unused permissions.

Anti-Patterns at a Glance #

// ✗ Database users with every privilege
GRANT ALL PRIVILEGES ON DATABASE myapp TO app_user;

// ✗ Service accounts with Editor/Owner roles
// roles/editor → can access and modify nearly every resource

// ✗ Tokens without scopes or expiry
token := jwt.New(jwt.SigningMethodHS256)
// No exp claim, no scope — valid forever for everything

// ✗ Admin roles implicitly getting every permission including new ones
// "Admin can do everything" — new permissions automatically enter admin without review

// ✗ Secrets in environment variables readable by every process
os.Getenv("DATABASE_PASSWORD") // available to all code in this process

// ✗ Service-to-service without authentication
http.Get("http://user-service/users/" + id) // no auth header

// ✗ Missed ownership checks — a customer can read another user's orders
func getOrder(w http.ResponseWriter, r *http.Request) {
    orderID := r.PathValue("id")
    order, _ := repo.FindByID(orderID)
    // No check whether order.UserID == currentUser.ID
    writeSuccess(w, 200, order) // information leakage!
}

// ✗ Device permissions requested before there's context
// Requesting the camera when the app first opens, not when scanning is needed

PoLP Review Checklist #

DATABASE:
  □ The app doesn't use a superuser or a user with GRANT OPTION
  □ Separate read and write users (when possible)
  □ Migration user separate from the runtime application user
  □ Per-table permissions — no wildcards across all tables

CLOUD IAM:
  □ Service accounts don't use primitive roles (Owner, Editor, Viewer)
  □ Custom roles with minimal permissions matching actual needs
  □ Workload Identity used (not JSON key files on disk)
  □ Permission reviews done periodically (quarterly)

API TOKENS AND OAUTH:
  □ Tokens have specific scopes — not full access
  □ Tokens have expiry matching the use case
  □ Unused tokens are revoked
  □ Refresh tokens outlive access tokens, but also have expiry

APPLICATION RBAC:
  □ Every role has an explicit, documented permission list
  □ New permissions don't automatically enter any role — must be explicit
  □ Ownership checks exist for per-user resources
  □ Denied access returns 404 not 403 to avoid information leakage

SECRET MANAGEMENT:
  □ Secrets stored in a secret manager, not env files or config files
  □ Each service can only access its own secrets — per-secret IAM bindings
  □ Secrets never logged or exposed in API responses
  □ Secret rotation scheduled with documented procedures

SERVICE-TO-SERVICE:
  □ All service-to-service calls authenticated (service tokens or mTLS)
  □ Service tokens have specific audience claims per target service
  □ Internal endpoints inaccessible from the public internet

AUDIT:
  □ All sensitive resource access recorded (actor, action, resource, result)
  □ Denied access attempts also recorded for anomaly detection
  □ Audit logs can't be modified by the audited service

Summary #

  • PoLP limits blast radius and attack surface: leaked credentials only provide access to a small subset of the system — isolated damage, not total compromise.
  • Databases: create separate users for write, read, and migrations. No wildcard privileges. Runtime users have no DROP, CREATE TABLE, or GRANT rights.
  • Cloud IAM: use custom roles with minimal permissions, not Editor or Owner. Workload Identity is safer than JSON key files. Review permissions every quarter.
  • API Tokens: tokens with specific scopes (order:read, not *:*), expiry matching the use case, and revocation when no longer needed.
  • Application RBAC: explicit permission matrices per role — admins don’t automatically get new permissions. Ownership checks for per-user resources. Return 404 not 403 to avoid information leakage.
  • Secret Management: each service can only access its own secrets via IAM bindings. Secrets never logged. Scheduled rotation.
  • Service-to-Service: every service calling another must be authenticated with service tokens carrying specific audience claims. No “the network is safe, no auth needed”.
  • Audit Trails: sensitive resource access must be recorded — including denials. Audit logs are the foundation for anomaly detection and incident investigation.
  • PoLP without audit = ineffective: permissions drift over time. Schedule periodic reviews and use cloud tools to identify unused permissions.
  • Proportional to risk: credit card data needs maximum granularity; public data needs only basic authentication. Don’t over-engineer PoLP for low-risk data.

← Previous: CoC  


Next: Data Integrity →
About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact