Repository: openpubkey/openpubkey
Pull request #280 (2025-03-01 19:19:22)

Uses less privileged user for SSH AuthorizedKeysCommand user
- Renames log file from `/var/log/openpubkey.log` to `/var/log/opkssh.log`
- Creates opksshuser to be used as less privileged AuthorizedKeyCommand user 
  - Fixes https://github.com/openpubkey/openpubkey/issues/276
- Creates gocode which can read policies in user's home directories because AuthorizedKeyCommand user is no longer root
- Gets sudoer behavior working in golang code eliminating need to additional script
  - Fixes https://github.com/openpubkey/openpubkey/issues/286
- Modifies install script so that it can install from a local binary, allowing us to delete the install command
  - Fixes https://github.com/openpubkey/openpubkey/issues/285
  
## How our less privileged AuthorizedKeyCommandUser works

OPKSSH requires that sshd run the AuthorizedKeyCommand using a specific user, a.k.a. the AuthorizedKeyCommandUser. Prior to this PR we had been using root for this. From a usability standpoint root is ideal. However from a security standpoint calling opkssh as root from sshd presents a security risk: if the opkssh was compromised, or tricked as in the case of [confused deputy attacks](https://en.wikipedia.org/wiki/Confused_deputy_problem), then an attacker might be able to assume root privileges. Put another way, we should not over-privilege the AuthorizedKeyCommand call to opkssh.

SSH recommends that AuthorizedKeyCommandUser be an unprivileged and dedicated user. This PR switches us to using such a user.

```bash
AuthorizedKeysCommand /usr/local/bin/opkssh verify %u %k %t
AuthorizedKeysCommandUser opksshuser
```

Rather than root we now create and use the linux user _opksshuser_ which is a member of the group _opksshgroup_. When opkssh is called as an AuthorizedKeysCommand by sshd it must read the following files:

- `/etc/opk/provider`,
-  `/etc/opk/auth_id` 
- `/home/{USER}/.opk/auth_id` where {USER} is the linux principal that the party sshing in is attempt to assume.

We set `/etc/opk/provider` and `/etc/opk/auth_id` to have the linux permissions `640` i.e. write/read by owner, read by group, nothing for everyone. We set the group of these two files to be opksshgroup. Since opksshuser is in the group opksshgroup it has read permissions for these files.

```bash
-rw-r----- 1 root opksshgroup   0 Mar 10 20:55 auth_id
-rw-r----- 1 root opksshgroup 310 Mar 10 20:55 providers
```

Reading the file  `/home/{USER}/.opk/auth_id` does not have such an easy solution. This is because while we can give opksshuser permission to read file `auth_id` in  `/home/{USER}/.opk/` it does not have permission to transverse the directory path `/home/{USER}/` to read the file. Changing the file permissions on a user's entire home directory is abusive and violates the common expectations. Additionally, if the user every changed the file permissions on their home directory back, we start failing.

We add a file to ` /etc/sudoers.d/` which contains:

```
opkssh ALL=(ALL) NOPASSWD: ALL=(ALL) NOPASSWD: /usr/local/bin/opkssh readhome *
```

The command `opkssh readhome <username>` outputs the contents of the policy file `/home/<username>/opk/.auth_id` policy file in the user's home directory for the specified `<username>`. To enable the opkssh process which is being run by the AuthorizedKeysCommand to specify any username, we use [the sudoers wildcard](https://linux.die.net/man/5/sudoers), 
where `*` allows any string of characters to be used.

While this is a security improvement over running a root AuthorizedKeysCommandUser, it does have some risks which we protect.

**Symlinks** First, users could put a symlinks in their home directory named `auth_id` and then attempt to connect over ssh to trick sshd into reading the file pointed to by the symlinks. Such an attack doesn't allow a user to read the file pointed to by the symlinks but they may be able to infer if the file exists and how large it is based on how long sshd takes to reject the connection.

To mitigate this risk we open the file with O_NO_FOLLOW to ensure if the file is a symlink we fail on file open. This makes the symlink check atomic with the file read and prevents the TOCTOU (Time-of-Check to Time-of-Use) vulnerability discussed in the notes below.

To ensure that `opkssh readhome` can't be used to read something which the user identified by the username is not allowed to read, readhome checks the following:
- the file is not a symlink,
- the file path is `~/.opk/auth_id`
- the user is the owner of the file
- the file has permissions `0600` rwx for owner and nothing for anyone else

## TODOs

 - [x] Add opksshuser and opksshgroup group create to install scripts
 - [x] Test on fresh install
- [X] Write up PR description
- [X] Document security properties of this change
- [X] Update all documentation around how to configure a system
- [x] Delete install command
- [x] Get intregration tests passing
- [x] Rearchitecture the file loaders so that they can handle different file permissions
- [X] opkssh must check that `opkssh_read_home.sh` has the correct permissions before calling it.
   - [X] Checks permissions
   - [x] Check owner of file is root
- [x] Ensure the sudoers script  `opkssh_read_home.sh` won't follow symlinks 
- [X] Update all the documentation that talks about file permissions, e.g. chmod 600 etc...
- [x] Resolve all TODO comments in code
- [x] Ensure policy loaders are clean and doc strings

After first review TODOs:
- [x] Use opkssh/opkssh instead of opksshuser/opksshgroup as the user/group roles are implied. Both are intended to refer to the same entity, so having different names is unusual and create more confusion.
- [x] `opkssh readhome <username>` for sudoer access to home policy.
- [x] instead create a file in /etc/sudoers.d/. Ideally create a single file that can be installed in distribution packages.
- [x] See if I can get away with not using any sudo commands in the install script
- [x] Figure out way to prevent "TOCTOU (time-of-check to time-of-use) race condition. Between checking with stat and reading the contents with cat, a local attacker can change the file that is being checked."

## Notes on TOCTOU symlink issue and alternative approaches 

These notes describe some approaches I attempted before deciding on the `readhome` approach. I wanted to document them in case I come back to this problem.

### Approach: opkssh readhome command

### Approach: stat and then cat

**Note we did not use this approach because this approach is secure**

We run `cat` and `stat` on  `/home/{USER}/.opk/auth_id` in opkssh to read these files. To enable this we must add the following lines to `/etc/sudoers`

```bash
opksshuser ALL=(ALL) NOPASSWD: /bin/cat /home/*/.opk/auth_id
opksshuser ALL=(ALL) NOPASSWD: /bin/stat *
```

Thus, to read `/home/{USER}/.opk/auth_id`, opkssh running as opksshuser  runs `stat` against `/home/{USER}/.opk/auth_id` to determine if the permissions are safe, and if the permissions are safe, it runs `cat /home/{USER}/.opk/auth_id` so that the opkssh binary can see the contents of the file.

While this is a security improvement over running a root AuthorizedKeysCommandUser, it does have some risks which we protect.

**Symlinks** First, users could put a symlinks in their home directory named `auth_id` and then attempt to connect over ssh to trick sshd into reading the file pointed to by the symlinks. Such an attack doesn't allow a user to read the file pointed to by the symlinks but they may be able to infer if the file exists and how large it is based on how long sshd takes to reject the connection.

We have mitigated this risk by checking if the file is symlink before reading it.  However this mitigation does not work because a user could perform the following attack.

1. Make `~/.opk/auth_id` not a symlink.
2. After `stat` runs and passes, but before cat runs, they user could change `~/.opk/auth_id`  into a symlink.

**edit:** There appears to be no way to atomically check if a file is a symlink while reading the file using linux commands. It requires sending the O_NO_FOLLOW flag on file open and linux commands do not expose this. I really think they should, but no one asked me.

### Approach: cat with the user whose home directory you are reading

Adding this to the sudoer file
```
opkssh ALL=(ALL) NOPASSWD: /bin/su [a-z0-9_-]+ -c '/bin/cat /home/[a-z0-9_-]+/.opk/auth_id'
```
 
This allows opkssh to assume any user identity but only to run cat for files matching the pattern `/home/*/.opk/auth_id`. 

If opkssh is malicious this is no worse or better than:

```
opkssh ALL=(ALL) NOPASSWD: /bin/cat /home/*/.opk/auth_id
```

since opkssh could run `/bin/su root -c '/bin/cat /home/<ANY USER>/.opk/auth_id`, but that was what it could do before.

However this defeats symlink attacks because if opkssh is honest it sets the username to the username of the home directory of the user it is reading the file. Thus, the read is limited by the users permissions, symlink or not. Consider the user eve. opkssh will run `/bin/su eve -c `/bin/cat /home/eve/.opk/auth_id`. If eve makes auth_id a symlink to a file she does not have permissions to read, the read will fail because the cat command is being run as eve.

**edit:** I couldn't get this approach to work because I couldn't get regex to work so I abandoned it

### Approach: cat with the user whose home directory you are reading

 sudo -u {newuser} cat .opk/auth_id and configure the sudoers file to restrict the command to exact that string (no wildcards) and a fixed working directory (requires sudo 1.9.3 or newer, does not work with Debian buster/oldstable, only bullseye/stable and newer).






Lekensteyn: Thank you for the detailed description! I have not looked thoroughly at the code yet, but based on the description I have some suggestions.

Nit: consider `opkssh`/`opkssh` instead of `opksshuser`/`opksshgroup` as the user/group roles are implied. Both are intended to refer to the same entity, so having different names is unusual and create more confusion.

Use of `sudo` creates an extra risk. While `cat /home/*/.opk/auth_id` should be safe (`*` does not match slashes according to [sudoers(7)](https://www.man7.org/linux/man-pages/man5/sudoers.5.html)), being able to check for arbitrary files as root user seems excessive. The current approach also has these issues:

- TOCTU (time-of-check to time-of-use) race condition. Between checking with stat and reading the contents with `cat`, a local attacker can change the file that is being checked. 
- If you are concerned about the `opksshuser` being compromised, then you should assume that it can execute `stat` on any file without further (symlink) checks given the `sudoers` config.

Some hardening suggestions/alternatives:
- Require filesystem support for ACLs (enabled by default), and use `setfacl -m u:opkssh:x /home/someuser /home/someuser/.opk` and `setfacl -m u:opkssh:r /home/someuser/.opk/auth_id` to set appropriate permissions. Alternatively, only use ACLs to set the `x` bit on the home directory, and change the group for the `.opk` directory and its contents to `opkssh` and grant it read (+descend for directories).
- Use an alternative shared directory with files for each user underneath, e.g. `/var/lib/opkssh/auth_ids/{username}` where the opkssh daemon can read any file and other system users can create files for themselves.
  * Set the owner/group to `root`/`opkssh`.
  * Change the mode such that the setgid (2000) bit is set (ensuring that newly created files have group `opkssh`), and the sticky bit (1000) is set, such that users can only delete files they own.
  * The final mode should be at least `3753`, granting the opkssh user read/descend access, and allowing the world to descend and write files. However given that other users can already get names and see the file sizes, you could also simply use `3777`.
  * When reading files, open the file descriptor, check that the owner of the file matches the user of the filename. File permissions takes care of the rest.
  * This scheme is vulnerable to a denial of service where local system users create files named after other users.
* If you want to continue to rely on `sudo`, consider `sudo -u {newuser} cat .opk/auth_id` and configure the sudoers file to restrict the command to exact that string (no wildcards) and a fixed working directory (requires sudo 1.9.3 or newer, does not work with Debian buster/oldstable, only bullseye/stable and newer).

Miscellaneous suggestions:
- Do not touch `/etc/sudoers/`, instead create a file in `/etc/sudoers.d/`. Ideally create a single file that can be installed in distribution packages.
- Be aware that the home directory is not necessarily `/home/{username}`. The Go code handles this well, but the `sudo` config does not.
EthanHeilman: Excellent feedback, thank you. I am updating my TODO list to fix these issues

> Require filesystem support for ACLs (enabled by default), and use setfacl -m u:opkssh:x /home/someuser /home/someuser/.opk and setfacl -m u:opkssh:r /home/someuser/.opk/auth_id to set appropriate permissions.

I've been hesitant to use filesystem ACLs because I'm worried about running into filesystems that don't support them and I'm worried about someone resetting the home directory ACL and then being locked out of their account. I can see someone removing +x on `/home/` without realizing they are going to hard lock themselves out of their server, whereas most ssh users are aware that changing permissions on `.ssh` and related files is dangerous.

> Use an alternative shared directory with files for each user underneath, e.g. /var/lib/opkssh/auth_ids/{username} where the opkssh daemon can read any file and other system users can create files for themselves.

I want to preserve the `~/.ssh/authorized_keys` pattern used by SSH. Putting it in `/var/lib` would solve a lot of problems. ChatGPT also suggested this. I wouldn't look there, if I was user. I want to copy the conventions used by SSH and SSH3.

I'm not concerned about this attack. If the attacker can alter the 

> If you want to continue to rely on sudo, consider sudo -u {newuser} cat .opk/auth_id and configure the sudoers file to restrict the command to exact that string (no wildcards) and a fixed working directory (requires sudo 1.9.3 or newer, does not work with Debian buster/oldstable, only bullseye/stable and newer).

Great call. I don't know why I didn't think about doing this, but this is an easy win. Thanks!

> Be aware that the home directory is not necessarily /home/{username}. The Go code handles this well, but the sudo config does not.

Created a ticket to track this. Don't plan to fix anytime soon, but it would be a nice feature to have and worth documenting we don't have it

https://github.com/openpubkey/openpubkey/issues/287





EthanHeilman: @Lekensteyn I'm not sure how your suggestion here would work

> If you want to continue to rely on sudo, consider sudo -u {newuser} cat .opk/auth_id and configure the sudoers file to restrict the command to exact that string (no wildcards) and a fixed working directory (requires sudo 1.9.3 or newer, does not work with Debian buster/oldstable, only bullseye/stable and newer).

Are you suggesting a sudoer line like:

```bash
opkssh ALL=(ALL) NOPASSWD: sudo -u [a-z0-9-_.]+ cat ~/.opk/auth_id
```

This might work if I can get regex in the sudoers file to work. So far I've been running into issues with sudo regex. I have a version that should support it, but it doesn't seem to work.

I am curious what happens if someone symlinks .opk/auth_id --> /dev/random. I really want to rule out symlinks to ensure no one figures some clever way of getting ssh to read `/proc` or `/dev`.

Patch:
diff --git a/opkssh/README.md b/opkssh/README.md
index 744f3a3..6e23a7d 100644
--- a/opkssh/README.md
+++ b/opkssh/README.md
@@ -1,18 +1,18 @@
 # OpenPubkey SSH (OPKSSH)
 
 opkssh is a tool which enables OpenID Connect to be used with ssh.
-It does not replace ssh, but rather generates ssh public keys that contain PK Tokens and then configures sshd to verify the PK Token in the ssh public keys.
+It does not replace ssh, but rather generates ssh public keys that contain PK Tokens and configures sshd to verify the PK Token in the ssh public keys.
 PK Tokens are a backwards compatible extension of ID Tokens which contain a public key.
 For more details on PK Tokens see [OpenPubkey](https://github.com/openpubkey/openpubkey/blob/main/README.md)
 
-Currently opkssh supports Google and Microsoft. If you have a gmail or microsoft email address you can ssh with that email address.
+Currently opkssh supports Google, Microsoft/Azure and Gitlab. If you have a gmail, microsoft or a gitlab account you can ssh with that account.
 
 ## Getting Started
 
 To configure a linux server to use opkssh simply run (root level privileges):
 
 ```bash
-wget -qO- "https://raw.githubusercontent.com/openpubkey/openpubkey/refs/tags/v0.5.2/opkssh/scripts/install-linux.sh"| sudo bash
+wget -qO- "https://raw.githubusercontent.com/openpubkey/openpubkey/main/opkssh/scripts/install-linux.sh" | sudo bash
 ```
 
  This will download the opkssh binary, install it as `/usr/local/bin/opkssh`, and then configure ssh to use opkssh as an additional authentication mechanism.
@@ -107,6 +107,13 @@ https://accounts.google.com 411517154569-7f10v0ftgp5elms1q8fm7avtp33t7i7n.apps.g
 https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096ce0a3-5e72-4da8-9c86-12924b294a01 24h
 ```
 
+`/etc/opk/providers` requires the following permissions (by default we create all configuration files with the correct permissions):
+
+```bash
+sudo chown root:opksshuser /etc/opk/providers
+sudo chmod 640 /etc/opk/providers
+```
+
 ## `/etc/opk/auth_id`
 
 `/etc/opk/auth_id` is the global authorized identities file.
@@ -140,8 +147,8 @@ This is especially useful in the case of azure where the issuer contains a long
 `/etc/opk/auth_id` requires the following permissions (by default we create all configuration files with the correct permissions):
 
 ```bash
-sudo chown root /etc/opk/auth_id
-sudo chmod 600 /etc/opk/auth_id
+sudo chown root:opksshuser /etc/opk/auth_id
+sudo chmod 640 /etc/opk/auth_id
 ```
 
 ### `~/.opk/auth_id`
@@ -164,58 +171,23 @@ chown {USER}:{USER} /home/{USER}/.opk/auth_id
 chmod 600 /home/{USER}/.opk/auth_id
 ```
 
-### Building From Scratch
-
-**1: Build opkssh.** Run the following from the root directory, replace GOARCH and GOOS to match with server you wish to install OPKSSH. This will generate the opkssh binary.
-
-```bash
-go build ./opkssh
-```
-
-**2: Copy opkssh to server.** Copy the opkssh binary you just built in the previous step to the SSH server you want to configure
-
-```bash
-scp opkssh ${USER}@${HOSTNAME}:~
-```
-
-**3: Install opkssh on server.** SSH to the server
-
-Create the following file directory structure on the server and move the executable there:
-
-```bash
-sudo mkdir /etc/opk
-sudo sudo mv ~/opkssh /usr/local/bin/opkssh
-sudo chown root /usr/local/bin/opkssh
-sudo chmod 700 /usr/local/bin/opkssh
-```
+### AuthorizedKeysCommandUser
 
-**3: Setup policy.** The file `/etc/opk/auth_id` controls which users and user identities can access the server using opkssh. If you do not have root access,
-create a new auth_id file in at ~/auth_id and use that instead. You
-will also need to have a opkssh binary available to use in the same directory.
+As recommended we use a low privileged user for the SSH AuthorizedKeysCommandUser.
+Our install script creates this user and group automatically by running:
 
 ```bash
-sudo touch /etc/opk/auth_id
-sudo chown root /etc/opk/auth_id
-sudo chmod 600 /etc/opk/auth_id
-sudo opkssh add {USER} {EMAIL} {ISSUER}
+sudo groupadd --system opksshuser
+sudo useradd -r -M -s /sbin/nologin -g opksshuser opksshuser
 ```
 
-**4: Configure sshd to use opkssh.** Add the following lines to the sshd configuration file `/etc/ssh/sshd_config`.
+We then add the following lines to `/etc/ssh/sshd_config`
 
 ```bash
 AuthorizedKeysCommand /usr/local/bin/opkssh verify %u %k %t
-AuthorizedKeysCommandUser root
+AuthorizedKeysCommandUser opksshuser
 ```
 
-**5: Restart sshd.**
-
-```bash
-sudo systemctl restart sshd
-```
-
-## Connecting via the Client
-
-You need to first make sure you have `opkssh`. You can build it locally in from the  `opkssh` directory in the `openpubkey` repo using `go build`.
+## More information
 
-1. Run `./opkssh login` this will open a browser window to authenticate to your OpenID Provider. After authenticating opkssh will generate an ssh key in your default `.ssh` directory.
-2. Then ssh to the server as you would normally `ssh {user}@ssh-server`
+We document how to manually install opkssh [here](https://raw.githubusercontent.com/openpubkey/openpubkey/main/opkssh/scripts/installing.md).
diff --git a/opkssh/commands/add.go b/opkssh/commands/add.go
index fe6a1d0..a8bb345 100644
--- a/opkssh/commands/add.go
+++ b/opkssh/commands/add.go
@@ -26,7 +26,8 @@ import (
 
 // AddCmd provides functionality to read and update the opkssh policy file
 type AddCmd struct {
-	PolicyFileLoader *policy.UserPolicyLoader
+	HomePolicyLoader   *policy.HomePolicyLoader
+	SystemPolicyLoader *policy.SystemPolicyLoader
 
 	// Username is the username to lookup when the system policy file cannot be
 	// read and we fallback to the user's policy file.
@@ -44,12 +45,12 @@ type AddCmd struct {
 // policy. Otherwise, a non-nil error is returned.
 func (a *AddCmd) LoadPolicy() (*policy.Policy, string, error) {
 	// Try to read system policy first
-	systemPolicy, err := a.PolicyFileLoader.LoadSystemDefaultPolicy()
+	systemPolicy, _, err := a.SystemPolicyLoader.LoadSystemPolicy()
 	if err != nil {
 		if errors.Is(err, os.ErrPermission) {
 			// If current process doesn't have permission, try reading the user
 			// policy file.
-			userPolicy, policyFilePath, err := a.PolicyFileLoader.LoadUserPolicy(a.Username, false)
+			userPolicy, policyFilePath, err := a.HomePolicyLoader.LoadHomePolicy(a.Username, false)
 			if err != nil {
 				return nil, "", err
 			}
@@ -65,39 +66,48 @@ func (a *AddCmd) LoadPolicy() (*policy.Policy, string, error) {
 }
 
 // GetPolicyPath returns the path to the policy file that the current command
-// will write to.
-func (a *AddCmd) GetPolicyPath(principal string, userEmail string, issuer string) (string, error) {
+// will write to and a boolean to flag the path is for home policy.
+// True means home policy, false means system policy.
+func (a *AddCmd) GetPolicyPath(principal string, userEmail string, issuer string) (string, bool, error) {
 	// Try to read system policy first
-	_, err := a.PolicyFileLoader.LoadSystemDefaultPolicy()
+	_, _, err := a.SystemPolicyLoader.LoadSystemPolicy()
 	if err != nil {
 		if errors.Is(err, os.ErrPermission) {
 			// If current process doesn't have permission, try reading the user
 			// policy file.
-			policyFilePath, err := a.PolicyFileLoader.UserPolicyPath(a.Username)
+			policyFilePath, err := a.HomePolicyLoader.UserPolicyPath(a.Username)
 			if err != nil {
-				return "", err
+				return "", false, err
 			}
-			return policyFilePath, nil
+			return policyFilePath, false, nil
 		} else {
 			// Non-permission error (e.g. system policy file missing or invalid
 			// permission bits set). Return error
-			return "", err
+			return "", false, err
 		}
 	}
-	return policy.SystemDefaultPolicyPath, nil
+	return policy.SystemDefaultPolicyPath, true, nil
 }
 
 // Add adds a new allowed principal to the user whose email is equal to
-// userEmail. The current policy file is read and modified.
+// userEmail. The policy file is read and modified.
 //
 // If successful, returns the policy filepath updated. Otherwise, returns a
 // non-nil error
 func (a *AddCmd) Add(principal string, userEmail string, issuer string) (string, error) {
-	policyPath, err := a.GetPolicyPath(principal, userEmail, issuer)
+	policyPath, homePolicy, err := a.GetPolicyPath(principal, userEmail, issuer)
 	if err != nil {
-		return "", fmt.Errorf("failed to load current policy: %w", err)
+		return "", fmt.Errorf("failed to load policy: %w", err)
+	}
+
+	var policyLoader *policy.PolicyLoader
+	if homePolicy {
+		policyLoader = a.HomePolicyLoader.PolicyLoader
+	} else {
+		policyLoader = a.SystemPolicyLoader.PolicyLoader
 	}
-	err = a.PolicyFileLoader.CreateIfDoesNotExist(policyPath)
+
+	err = policyLoader.CreateIfDoesNotExist(policyPath)
 	if err != nil {
 		return "", fmt.Errorf("failed to create policy file: %w", err)
 	}
@@ -112,7 +122,7 @@ func (a *AddCmd) Add(principal string, userEmail string, issuer string) (string,
 	currentPolicy.AddAllowedPrincipal(principal, userEmail, issuer)
 
 	// Dump contents back to disk
-	err = a.PolicyFileLoader.Dump(currentPolicy, policyFilePath)
+	err = policyLoader.Dump(currentPolicy, policyFilePath)
 	if err != nil {
 		return "", fmt.Errorf("failed to write updated policy: %w", err)
 	}
diff --git a/opkssh/commands/install.go b/opkssh/commands/install.go
deleted file mode 100644
index f83b7bd..0000000
--- a/opkssh/commands/install.go
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright 2025 OpenPubkey
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-// SPDX-License-Identifier: Apache-2.0
-
-package commands
-
-import (
-	"fmt"
-	"os"
-	"os/exec"
-)
-
-// Install setups opkssh on a server
-func Install() error {
-	if os.Geteuid() != 0 {
-		return fmt.Errorf("this command must be run as root or sudo")
-	}
-
-	opksshExePath, err := os.Executable()
-	if err != nil {
-		return fmt.Errorf("failed to get executable path: %v", err)
-	}
-
-	installScript := `
-#!/bin/sh
-
-PROVIDER_GOOGLE="https://accounts.google.com 411517154569-7f10v0ftgp5elms1q8fm7avtp33t7i7n.apps.googleusercontent.com 24h"
-PROVIDER_MICROSOFT="https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096ce0a3-5e72-4da8-9c86-12924b294a01 24h"
-PROVIDER_GITLAB="https://gitlab.com 8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923 24h"
-
-sudo mkdir -p /etc/opk
-sudo touch /etc/opk/auth_id
-sudo chown root /etc/opk/auth_id
-sudo chmod 600 /etc/opk/auth_id
-
-sudo touch /etc/opk/providers
-sudo chown root /etc/opk/providers
-sudo chmod 600 /etc/opk/providers
-
-if [ -s /etc/opk/providers ]; then
-	echo "The providers policy file (/etc/opk/providers) is not empty. Keeping existing values"
-else
-	echo "$PROVIDER_GOOGLE" >> /etc/opk/providers
-	echo "$PROVIDER_MICROSOFT" >> /etc/opk/providers
-	echo "$PROVIDER_GITLAB" >> /etc/opk/providers
-fi
-
-sed -i '/^AuthorizedKeysCommand /s/^/#/' /etc/ssh/sshd_config
-sed -i '/^AuthorizedKeysCommandUser /s/^/#/' /etc/ssh/sshd_config
-echo "AuthorizedKeysCommand /usr/local/bin/opkssh verify %u %k %t\nAuthorizedKeysCommandUser root" >> /etc/ssh/sshd_config
-sudo systemctl restart ssh
-
-`
-	installScript += fmt.Sprintf("sudo cp %s /usr/local/bin/opkssh\n", opksshExePath)
-
-	cmd := exec.Command("sh", "-c", installScript)
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
-	err = cmd.Run()
-	if err != nil {
-		return fmt.Errorf("failed to run script: %v", err)
-	}
-
-	return nil
-}
diff --git a/opkssh/commands/readhome_linux.go b/opkssh/commands/readhome_linux.go
new file mode 100644
index 0000000..068cd8d
--- /dev/null
+++ b/opkssh/commands/readhome_linux.go
@@ -0,0 +1,92 @@
+// Copyright 2025 OpenPubkey
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+//go:build linux || darwin
+
+package commands
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"os/user"
+	"path/filepath"
+	"regexp"
+	"strconv"
+	"syscall"
+
+	"github.com/openpubkey/openpubkey/opkssh/policy/files"
+)
+
+// ReadHome is used to read the home policy file for the user with
+// the specified username. This is used when opkssh is called by
+// AuthorizedKeysCommand as the opksshuser and needs to use sudoer
+// access to read the home policy file (`/home/<username>/opk/auth_id`).
+// This function is only available on Linux and Darwin because it relies on
+// syscall.Stat_t to determine the owner of the file.
+func ReadHome(username string) ([]byte, error) {
+	if matched, _ := regexp.MatchString("^[a-z0-9_\\-.]+$", username); !matched {
+		return nil, fmt.Errorf("%s is not a valid linux username", username)
+	}
+
+	userObj, err := user.Lookup(username)
+	if err != nil {
+		return nil, fmt.Errorf("failed to find user %s", username)
+	}
+	homePolicyPath := filepath.Join(userObj.HomeDir, ".opk", "auth_id")
+
+	// Security critical: We reading this file as `sudo -u opksshuser`
+	// and opksshuser has elevated permissions to read any file whose
+	// path matches `/home/*/opk/auth_id`. We need to be cautious we do follow
+	// a symlink as it could be to a file the user is not permitted to read.
+	// This would not permit the user to read the file, but they might be able
+	// to determine the existence of the file. We use O_NOFOLLOW to prevent
+	// following symlinks.
+	file, err := os.OpenFile(homePolicyPath, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
+	if err != nil {
+		if errors.Is(err, syscall.ELOOP) {
+			return nil, fmt.Errorf("home policy file %s is a symlink, symlink are unsafe in this context", homePolicyPath)
+		}
+		return nil, fmt.Errorf("failed to open %s, %v", homePolicyPath, err)
+	}
+	defer file.Close()
+
+	if fileInfo, err := file.Stat(); err != nil {
+		return nil, fmt.Errorf("failed to get info on file %s", homePolicyPath)
+	} else if stat, ok := fileInfo.Sys().(*syscall.Stat_t); !ok { // This syscall.Stat_t is doesn't work on Windows
+		return nil, fmt.Errorf("failed to stat file %s", homePolicyPath)
+	} else {
+		// We want to ensure that the file is owned by the correct user and has the correct permissions.
+		requiredOwnerUid := userObj.Uid
+		fileOwnerUID := strconv.FormatUint(uint64(stat.Uid), 10)
+		fileOwner, err := user.LookupId(fileOwnerUID)
+		if err != nil {
+			return nil, fmt.Errorf("failed to find username for UID %s for file %s", fileOwnerUID, homePolicyPath)
+		}
+		if fileOwnerUID != userObj.Uid || fileOwner.Username != username {
+			return nil, fmt.Errorf("unsafe file permissions on %s expected file owner %s (UID %s) got %s (UID %s)",
+				homePolicyPath, username, requiredOwnerUid, fileOwner.Username, fileOwnerUID)
+		}
+		if fileInfo.Mode().Perm() != files.ModeHomePerms {
+			return nil, fmt.Errorf("unsafe file permissions for %s got %o expected %o", homePolicyPath, fileInfo.Mode().Perm(), files.ModeHomePerms)
+		}
+		fileBytes, err := os.ReadFile(homePolicyPath)
+		if err != nil {
+			return nil, fmt.Errorf("failed to read %s, %v", homePolicyPath, err)
+		}
+		return fileBytes, nil
+	}
+}
diff --git a/opkssh/commands/readhome_windows.go b/opkssh/commands/readhome_windows.go
new file mode 100644
index 0000000..86f70a7
--- /dev/null
+++ b/opkssh/commands/readhome_windows.go
@@ -0,0 +1,28 @@
+// Copyright 2025 OpenPubkey
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+//go:build windows
+
+package commands
+
+import (
+	"errors"
+)
+
+// ReadHome is not currently supported on Windows
+func ReadHome(username string) ([]byte, error) {
+	return nil, errors.New("readhome not supported on windows")
+}
diff --git a/opkssh/commands/verify.go b/opkssh/commands/verify.go
index f9230e4..6f0f04a 100644
--- a/opkssh/commands/verify.go
+++ b/opkssh/commands/verify.go
@@ -48,7 +48,7 @@ type VerifyCmd struct {
 // The following lines are added to /etc/ssh/sshd_config:
 //
 //	AuthorizedKeysCommand /usr/local/bin/opkssh ver %u %k %t
-//	AuthorizedKeysCommandUser root
+//	AuthorizedKeysCommandUser opksshuser
 //
 // The parameters specified in the config map the parameters sent to the function below.
 // We prepend "Arg" to specify which ones are arguments sent by sshd. They are:
@@ -93,9 +93,11 @@ func (v *VerifyCmd) AuthorizedKeysCommand(ctx context.Context, userArg string, t
 // used in the opkssh verify command.
 func OpkPolicyEnforcerFunc(username string) PolicyEnforcerFunc {
 	policyEnforcer := &policy.Enforcer{
-		PolicyLoader: &policy.UserMultiFileLoader{
-			UserPolicyLoader: policy.NewUserFileLoader(),
-			Username:         username,
+		PolicyLoader: &policy.MultiPolicyLoader{
+			HomePolicyLoader:   policy.NewHomePolicyLoader(),
+			SystemPolicyLoader: policy.NewSystemPolicyLoader(),
+			Username:           username,
+			LoadWithScript:     true, // This is needed to load policy from the user's home directory
 		},
 	}
 	return policyEnforcer.CheckPolicy
diff --git a/opkssh/docs/config.md b/opkssh/docs/config.md
index ad60eb3..6bf8d5e 100644
--- a/opkssh/docs/config.md
+++ b/opkssh/docs/config.md
@@ -63,8 +63,8 @@ This is especially useful in the case of azure where the issuer contains a long
 It require the following permissions:
 
 ```bash
-sudo chown root /etc/opk/auth_id
-sudo chmod 600 /etc/opk/auth_id
+sudo chown root:opksshuser /etc/opk/auth_id
+sudo chmod 640 /etc/opk/auth_id
 ```
 
 `sudo opkssh add root alice@example.com google`
@@ -95,24 +95,24 @@ sudo apt install openssh-server
 
 sudo mkdir -p /etc/opk
 sudo touch /etc/opk/auth_id
-sudo chown root /etc/opk/auth_id
-sudo chmod 600 /etc/opk/auth_id
+sudo chown root:opksshuser /etc/opk/auth_id
+sudo chmod 640 /etc/opk/auth_id
 
 cd /tmp
 git clone https://github.com/openpubkey/openpubkey.git
 cd openpubkey
 sudo go build -v -o /usr/local/bin/opkssh ./opkssh
-sudo chmod 700 /usr/local/bin/opkssh
+sudo chmod 711 /usr/local/bin/opkssh
 sudo chown root /usr/local/bin/opkssh
 
 sudo touch /etc/opk/providers
-sudo chown root /etc/opk/providers
-sudo chmod 600 /etc/opk/providers
+sudo chown root:opksshuser /etc/opk/providers
+sudo chmod 640 /etc/opk/providers
 
 sudo su
 sed -i '/^AuthorizedKeysCommand /s/^/#/' /etc/ssh/sshd_config
 sed -i '/^AuthorizedKeysCommandUser /s/^/#/' /etc/ssh/sshd_config
-echo "AuthorizedKeysCommand /usr/local/bin/opkssh verify %u %k %t\nAuthorizedKeysCommandUser root" >> /etc/ssh/sshd_config
+echo "AuthorizedKeysCommand /usr/local/bin/opkssh verify %u %k %t\nAuthorizedKeysCommandUser opksshuser" >> /etc/ssh/sshd_config
 sudo systemctl restart ssh
 
 echo "https://accounts.google.com 411517154569-7f10v0ftgp5elms1q8fm7avtp33t7i7n.apps.googleusercontent.com 24h" >> /etc/opk/providers
diff --git a/opkssh/main.go b/opkssh/main.go
index 573f1e4..d1816f7 100644
--- a/opkssh/main.go
+++ b/opkssh/main.go
@@ -42,11 +42,12 @@ var (
 
 	// These can be overridden at build time using ldflags. For example:
 	// go build -v -o /usr/local/bin/opkssh -ldflags "-X main.issuer=http://oidc.local:${ISSUER_PORT}/ -X main.clientID=web -X main.clientSecret=secret"
-	Version      = "unversioned"
-	issuer       = ""
-	clientID     = ""
-	clientSecret = ""
-	redirectURIs = ""
+	Version           = "unversioned"
+	issuer            = ""
+	clientID          = ""
+	clientSecret      = ""
+	redirectURIs      = ""
+	logFilePathServer = "/var/log/opkssh.log" // Remember if you change this, change it in the install script as well
 )
 
 func main() {
@@ -55,7 +56,7 @@ func main() {
 
 func run() int {
 	if len(os.Args) < 2 {
-		fmt.Println("OPKSSH (OpenPubkey SSH) CLI: command choices are: login, verify, add, and install")
+		fmt.Println("OPKSSH (OpenPubkey SSH) CLI: command choices are: login, verify, and add")
 		return 1
 	}
 	command := os.Args[1]
@@ -90,12 +91,13 @@ func run() int {
 
 		// If a log directory was provided, write any logs to a file in that directory AND stdout
 		if *logFilePath != "" {
-			logFilePath := filepath.Join(*logFilePath, "openpubkey.log")
-			logFile, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0700)
+			logFilePath := filepath.Join(*logFilePath, "opkssh.log")
+			logFile, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0660)
 			if err == nil {
 				defer logFile.Close()
 				multiWriter := io.MultiWriter(os.Stdout, logFile)
 				log.SetOutput(multiWriter)
+				log.Printf("Failed to open log for writing: %v \n", err)
 			}
 		}
 
@@ -146,9 +148,11 @@ func run() int {
 		}
 	case "verify":
 		// Setup logger
-		logFile, err := os.OpenFile("/var/log/openpubkey.log", os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0700)
+		logFile, err := os.OpenFile(logFilePathServer, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0660) // Owner and group can read/write
 		if err != nil {
 			fmt.Println("ERROR opening log file:", err)
+			// It could be very difficult to figure out what is going on if the log file was deleted. Hopefully this message saves someone an hour of debugging.
+			fmt.Printf("Check if log exists at %v, if it does not create it with permissions: chown root:opksshuser %v; chmod 660 %v\n", logFilePathServer, logFilePathServer, logFilePathServer)
 		} else {
 			defer logFile.Close()
 			log.SetOutput(logFile)
@@ -235,8 +239,9 @@ func run() int {
 
 		// Execute add command
 		a := commands.AddCmd{
-			PolicyFileLoader: policy.NewUserFileLoader(),
-			Username:         inputPrincipal,
+			HomePolicyLoader:   policy.NewHomePolicyLoader(),
+			SystemPolicyLoader: policy.NewSystemPolicyLoader(),
+			Username:           inputPrincipal,
 		}
 		if policyFilePath, err := a.Add(inputPrincipal, inputEmail, inputIssuer); err != nil {
 			fmt.Println("failed to add to policy:", err)
@@ -244,22 +249,24 @@ func run() int {
 		} else {
 			fmt.Println("Successfully added new policy to", policyFilePath)
 		}
-	case "install":
-		// The "install" command is sets up and installs opkssh on a server.
-		// It currently has only been tested on Ubuntu. It must be run as root.
-		//
-		// Example line:
-		// 		./opkssh install
-		err := commands.Install()
-		if err != nil {
-			fmt.Println("failed to install opkssh:", err)
+	case "--version", "-v":
+		fmt.Println(Version)
+	case "readhome":
+		// This command called as part of AuthorizedKeysCommand. It is used to
+		// read the user's home policy file (`~/.opk/auth_id`) with sudoer permissions.
+		// This allows us to use an unprivileged user as the AuthorizedKeysCommand user.
+
+		if len(os.Args) != 3 {
+			fmt.Println("Invalid number of arguments for readhome, expected: `<username>`")
+			return 1
+		}
+		userArg := os.Args[2]
+		if fileBytes, err := commands.ReadHome(userArg); err != nil {
+			fmt.Printf("Failed to read user's home policy file: %v\n", err)
 			return 1
 		} else {
-			fmt.Println("successfully installed opkssh")
-			return 0
+			fmt.Println(string(fileBytes))
 		}
-	case "--version", "-v":
-		fmt.Println(Version)
 	default:
 		fmt.Println("ERROR! Unrecognized command:", command)
 		return 1
diff --git a/opkssh/policy/enforcer.go b/opkssh/policy/enforcer.go
index 0187d3e..2bb0224 100644
--- a/opkssh/policy/enforcer.go
+++ b/opkssh/policy/enforcer.go
@@ -35,7 +35,9 @@ type Enforcer struct {
 // email claim. Returns nil if access is granted. Otherwise, an error is
 // returned.
 //
-// It is recommended to verify the pkt first before calling this function.
+// It is security critical to verify the pkt first before calling this function.
+// This is because if this function is called first, a timing channel exists which
+// allows an attacker check what identities and principals are allowed by the policy.
 func (p *Enforcer) CheckPolicy(principalDesired string, pkt *pktoken.PKToken) error {
 	policy, source, err := p.PolicyLoader.Load()
 	if err != nil {
diff --git a/opkssh/policy/fileloader.go b/opkssh/policy/files/fileloader.go
similarity index 72%
rename from opkssh/policy/fileloader.go
rename to opkssh/policy/files/fileloader.go
index 0870f3e..ccd5068 100644
--- a/opkssh/policy/fileloader.go
+++ b/opkssh/policy/files/fileloader.go
@@ -14,7 +14,7 @@
 //
 // SPDX-License-Identifier: Apache-2.0
 
-package policy
+package files
 
 import (
 	"fmt"
@@ -23,29 +23,30 @@ import (
 	"github.com/spf13/afero"
 )
 
-// ModeOnlyOwner is the expected permission bits that should be set for opkssh
-// policy files. This mode means that only the owner of the file can read/write
-const ModeOnlyOwner = fs.FileMode(0600)
-
 // UserPolicyLoader contains methods to read/write the opkssh policy file from/to an
 // arbitrary filesystem. All methods that read policy from the filesystem fail
 // and return an error immediately if the permission bits are invalid.
 type FileLoader struct {
-	Fs afero.Fs
+	Fs           afero.Fs
+	RequiredPerm fs.FileMode
 }
 
+// CreateIfDoesNotExist creates a file at the given path if it does not exist.
 func (l FileLoader) CreateIfDoesNotExist(path string) error {
 	exists, err := afero.Exists(l.Fs, path)
 	if err != nil {
 		return err
 	}
 	if !exists {
+		if err := l.Fs.MkdirAll(afero.GetTempDir(l.Fs, path), 0750); err != nil {
+			return fmt.Errorf("failed to create directory: %w", err)
+		}
 		file, err := l.Fs.Create(path)
 		if err != nil {
 			return fmt.Errorf("failed to create file: %w", err)
 		}
 		file.Close()
-		if err := l.Fs.Chmod(path, ModeOnlyOwner); err != nil {
+		if err := l.Fs.Chmod(path, l.RequiredPerm); err != nil {
 			return fmt.Errorf("failed to set file permissions: %w", err)
 		}
 	}
@@ -57,15 +58,13 @@ func (l FileLoader) CreateIfDoesNotExist(path string) error {
 // contents and returns the bytes if file permissions are valid and
 // reading is successful; otherwise returns an error.
 func (l *FileLoader) LoadFileAtPath(path string) ([]byte, error) {
-	// Get file info and check if file exists
-	info, err := l.Fs.Stat(path)
-	if err != nil {
+	// Check if file exists and we can access it
+	if _, err := l.Fs.Stat(path); err != nil {
 		return nil, fmt.Errorf("failed to describe the file at path: %w", err)
 	}
 
 	// Validate that file has correct permission bits set
-	err = l.validatePermissions(info)
-	if err != nil {
+	if err := NewPermsChecker(l.Fs).CheckPerm(path, l.RequiredPerm, "", ""); err != nil {
 		return nil, fmt.Errorf("policy file has insecure permissions: %w", err)
 	}
 
@@ -78,21 +77,10 @@ func (l *FileLoader) LoadFileAtPath(path string) ([]byte, error) {
 	return content, nil
 }
 
-func (l *FileLoader) validatePermissions(fileInfo fs.FileInfo) error {
-	mode := fileInfo.Mode()
-
-	// only the owner of this file should be able to write to it
-	if mode.Perm() != ModeOnlyOwner {
-		return fmt.Errorf("expected (0600), got (%o)", mode.Perm())
-	}
-
-	return nil
-}
-
-// Dump writes fileBytes to the filepath
+// Dump writes the bytes in fileBytes to the filepath
 func (l *FileLoader) Dump(fileBytes []byte, path string) error {
 	// Write to disk
-	if err := afero.WriteFile(l.Fs, path, fileBytes, ModeOnlyOwner); err != nil {
+	if err := afero.WriteFile(l.Fs, path, fileBytes, l.RequiredPerm); err != nil {
 		return err
 	}
 	return nil
diff --git a/opkssh/policy/files/permschecker.go b/opkssh/policy/files/permschecker.go
new file mode 100644
index 0000000..11b16f4
--- /dev/null
+++ b/opkssh/policy/files/permschecker.go
@@ -0,0 +1,99 @@
+// Copyright 2025 OpenPubkey
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package files
+
+import (
+	"fmt"
+	"io/fs"
+	"log"
+	"os/exec"
+	"strings"
+
+	"github.com/spf13/afero"
+)
+
+// ModeSystemPerms is the expected permission bits that should be set for opkssh
+// system policy files (`/etc/opk/auth_id`, `/etc/opk/providers`). This mode means
+// that only the owner of the file can write/read to the file, but the group which
+// should be opksshuser can read the file.
+const ModeSystemPerms = fs.FileMode(0640)
+
+// ModeHomePerms is the expected permission bits that should be set for opkssh
+// user home policy files `~/.opk/auth_id`.
+const ModeHomePerms = fs.FileMode(0600)
+
+// PermsChecker contains methods to check the ownership, group
+// and file permissions of a file on a Unix-like system.
+type PermsChecker struct {
+	Fs        afero.Fs
+	cmdRunner func(string, ...string) ([]byte, error)
+}
+
+func NewPermsChecker(fs afero.Fs) *PermsChecker {
+	return &PermsChecker{Fs: fs, cmdRunner: execCmd}
+}
+
+// CheckPerm checks the file at the given path if it has the desired permissions.
+// If the requiredOwner or requiredGroup are not empty then the function will also
+// that the owner and group of the file match the requiredOwner and requiredGroup
+// specified and fail if they do not.
+func (u *PermsChecker) CheckPerm(path string, requirePerm fs.FileMode, requiredOwner string, requiredGroup string) error {
+	fileInfo, err := u.Fs.Stat(path)
+	if err != nil {
+		return fmt.Errorf("failed to describe the file at path: %w", err)
+	}
+	mode := fileInfo.Mode()
+
+	// if the requiredOwner or requiredGroup are specified then run stat and check if they match
+	if requiredOwner != "" || requiredGroup != "" {
+		log.Println("Running, command: ", "stat", "-c", "%U %G", path)
+		statOutput, err := u.cmdRunner("stat", "-c", "%U %G", path)
+		log.Println("Got output:", string(statOutput))
+		if err != nil {
+			return fmt.Errorf("failed to run stat: %w", err)
+		}
+
+		statOutputSplit := strings.Split(strings.TrimSpace(string(statOutput)), " ")
+		statOwner := statOutputSplit[0]
+		statGroup := statOutputSplit[1]
+		if len(statOutputSplit) != 2 {
+			return fmt.Errorf("expected stat command to return 2 values got %d", len(statOutputSplit))
+		}
+
+		if requiredOwner != "" {
+			if requiredOwner != statOwner {
+				return fmt.Errorf("expected owner (%s), got (%s)", requiredOwner, statOwner)
+			}
+		}
+		if requiredGroup != "" {
+			if requiredGroup != statGroup {
+				return fmt.Errorf("expected group (%s), got (%s)", requiredGroup, statGroup)
+			}
+		}
+	}
+
+	if mode.Perm() != requirePerm {
+		return fmt.Errorf("expected permissions (%o), got (%o)", requirePerm.Perm(), mode.Perm())
+	}
+
+	return nil
+}
+
+func execCmd(name string, arg ...string) ([]byte, error) {
+	cmd := exec.Command(name, arg...)
+	return cmd.CombinedOutput()
+}
diff --git a/opkssh/policy/usermultifileloader.go b/opkssh/policy/multipolicyloader.go
similarity index 56%
rename from opkssh/policy/usermultifileloader.go
rename to opkssh/policy/multipolicyloader.go
index 53752ba..62f8e59 100644
--- a/opkssh/policy/usermultifileloader.go
+++ b/opkssh/policy/multipolicyloader.go
@@ -18,11 +18,13 @@ package policy
 
 import (
 	"errors"
+	"fmt"
 	"log"
+	"os/exec"
 	"strings"
 )
 
-var _ Loader = &UserMultiFileLoader{}
+var _ Loader = &MultiPolicyLoader{}
 
 // FileSource implements policy.Source by returning a string that is expected to
 // be a filepath
@@ -32,26 +34,26 @@ func (s FileSource) Source() string {
 	return string(s)
 }
 
-// UserMultiFileLoader implements policy.Loader by reading both the system default
+// MultiPolicyLoader implements policy.Loader by reading both the system default
 // policy (root policy) and user policy (~/.opk/auth_id where ~ maps to
 // Username's home directory)
-type UserMultiFileLoader struct {
-	*UserPolicyLoader
-
-	Username string
+type MultiPolicyLoader struct {
+	HomePolicyLoader   *HomePolicyLoader
+	SystemPolicyLoader *SystemPolicyLoader
+	LoadWithScript     bool
+	Username           string
 }
 
-func (l *UserMultiFileLoader) Load() (*Policy, Source, error) {
+func (l *MultiPolicyLoader) Load() (*Policy, Source, error) {
 	policy := new(Policy)
 
 	// Try to load the root policy
-	rootPolicy, rootPolicyErr := l.LoadSystemDefaultPolicy()
+	rootPolicy, _, rootPolicyErr := l.SystemPolicyLoader.LoadSystemPolicy()
 	if rootPolicyErr != nil {
 		log.Println("warning: failed to load system default policy:", rootPolicyErr)
 	}
-
 	// Try to load the user policy
-	userPolicy, userPolicyFilePath, userPolicyErr := l.LoadUserPolicy(l.Username, true)
+	userPolicy, userPolicyFilePath, userPolicyErr := l.HomePolicyLoader.LoadHomePolicy(l.Username, true, ReadWithSudoScript)
 	if userPolicyErr != nil {
 		log.Println("warning: failed to load user policy:", userPolicyErr)
 	}
@@ -80,3 +82,24 @@ func (l *UserMultiFileLoader) Load() (*Policy, Source, error) {
 
 	return policy, FileSource(strings.Join(readPaths, ", ")), nil
 }
+
+// ReadWithSudoScript specifies additional way of loading the policy in the
+// user's home directory (`~/.opk/auth_id`). This is needed when the
+// AuthorizedKeysCommand user does not have privileges to transverse the user's
+// home directory. Instead we call run a command which uses special
+// sudoers permissions to read the policy file.
+//
+// Doing this is more secure than simply giving opkssh sudoer access because
+// if there was an RCE in opkssh could be triggered an SSH request via
+// AuthorizedKeysCommand, the new opkssh process we use to perform the read
+// would not be compromised. Thus, the compromised opkssh process could not assume
+// full root privileges.
+func ReadWithSudoScript(h *HomePolicyLoader, username string) ([]byte, error) {
+	// opkssh readhome ensures the file is not a symlink and has the permissions/ownership.
+	cmd := exec.Command("sudo", "-n", "/usr/local/bin/opkssh", "readhome", username)
+	homePolicyFileBytes, err := cmd.CombinedOutput()
+	if err != nil {
+		return nil, fmt.Errorf("error reading %s home policy using command %v got output %v and err %v", username, cmd, string(homePolicyFileBytes), err)
+	}
+	return homePolicyFileBytes, nil
+}
diff --git a/opkssh/policy/userpolicy.go b/opkssh/policy/policy.go
similarity index 99%
rename from opkssh/policy/userpolicy.go
rename to opkssh/policy/policy.go
index a4c35c8..802c008 100644
--- a/opkssh/policy/userpolicy.go
+++ b/opkssh/policy/policy.go
@@ -44,7 +44,6 @@ type Policy struct {
 
 // FromTable decodes whitespace delimited input into policy.Policy
 func FromTable(input []byte, path string) *Policy {
-
 	table := files.NewTable(input)
 	policy := &Policy{}
 	for i, row := range table.GetRows() {
diff --git a/opkssh/policy/userpolicy_test.go b/opkssh/policy/policy_test.go
similarity index 100%
rename from opkssh/policy/userpolicy_test.go
rename to opkssh/policy/policy_test.go
diff --git a/opkssh/policy/userfileloader.go b/opkssh/policy/policyloader.go
similarity index 53%
rename from opkssh/policy/userfileloader.go
rename to opkssh/policy/policyloader.go
index f2edf86..04b6f81 100644
--- a/opkssh/policy/userfileloader.go
+++ b/opkssh/policy/policyloader.go
@@ -21,6 +21,7 @@ import (
 	"os/user"
 	"path"
 
+	"github.com/openpubkey/openpubkey/opkssh/policy/files"
 	"github.com/spf13/afero"
 	"golang.org/x/exp/slices"
 )
@@ -44,26 +45,15 @@ func NewOsUserLookup() UserLookup {
 }
 func (OsUserLookup) Lookup(username string) (*user.User, error) { return user.Lookup(username) }
 
-// UserPolicyLoader contains methods to read/write the opkssh policy file from/to an
+// PolicyLoader contains methods to read/write the opkssh policy file from/to an
 // arbitrary filesystem. All methods that read policy from the filesystem fail
 // and return an error immediately if the permission bits are invalid.
-type UserPolicyLoader struct {
-	FileLoader FileLoader
+type PolicyLoader struct {
+	FileLoader files.FileLoader
 	UserLookup UserLookup
 }
 
-// NewUserFileLoader returns an opkssh policy loader that uses the os library to
-// read/write policy from/to the filesystem.
-func NewUserFileLoader() *UserPolicyLoader {
-	return &UserPolicyLoader{
-		FileLoader: FileLoader{
-			Fs: afero.NewOsFs(),
-		},
-		UserLookup: NewOsUserLookup(),
-	}
-}
-
-func (l UserPolicyLoader) CreateIfDoesNotExist(path string) error {
+func (l PolicyLoader) CreateIfDoesNotExist(path string) error {
 	return l.FileLoader.CreateIfDoesNotExist(path)
 }
 
@@ -71,8 +61,7 @@ func (l UserPolicyLoader) CreateIfDoesNotExist(path string) error {
 // by the current process, and has the correct permission bits set. Parses the
 // contents and returns a policy.Policy if file permissions are valid and
 // reading is successful; otherwise returns an error.
-func (l *UserPolicyLoader) LoadPolicyAtPath(path string) (*Policy, error) {
-
+func (l *PolicyLoader) LoadPolicyAtPath(path string) (*Policy, error) {
 	content, err := l.FileLoader.LoadFileAtPath(path)
 	if err != nil {
 		return nil, err
@@ -82,35 +71,78 @@ func (l *UserPolicyLoader) LoadPolicyAtPath(path string) (*Policy, error) {
 	return policy, nil
 }
 
-// LoadSystemDefaultPolicy reads the opkssh policy at SystemDefaultPolicyPath.
-// An error is returned if the file cannot be read or if the permissions bits
-// are not correct.
-func (l *UserPolicyLoader) LoadSystemDefaultPolicy() (*Policy, error) {
-	policy, err := l.LoadPolicyAtPath(SystemDefaultPolicyPath)
+// Dump encodes the policy into file and writes the contents to the filepath
+// path
+func (l *PolicyLoader) Dump(policy *Policy, path string) error {
+	fileBytes, err := policy.ToTable()
 	if err != nil {
-		return nil, fmt.Errorf("failed to read system default policy file %s: %w", SystemDefaultPolicyPath, err)
+		return err
 	}
 
-	return policy, nil
+	// Write to disk
+	if err := l.FileLoader.Dump(fileBytes, path); err != nil {
+		return fmt.Errorf("failed to write to policy file %s: %w", path, err)
+	}
+
+	return nil
 }
 
-// UserPolicyPath returns the path to the user's opkssh policy file at
-// ~/.opk/auth_id.
-func (l *UserPolicyLoader) UserPolicyPath(username string) (string, error) {
-	user, err := l.UserLookup.Lookup(username)
-	if err != nil {
-		return "", fmt.Errorf("failed to lookup username %s: %w", username, err)
+// NewSystemPolicyLoader returns an opkssh policy loader that uses the os library to
+// read/write system policy from/to the filesystem.
+func NewSystemPolicyLoader() *SystemPolicyLoader {
+	return &SystemPolicyLoader{
+		PolicyLoader: &PolicyLoader{
+			FileLoader: files.FileLoader{
+				Fs:           afero.NewOsFs(),
+				RequiredPerm: files.ModeSystemPerms,
+			},
+			UserLookup: NewOsUserLookup(),
+		},
 	}
-	userHomeDirectory := user.HomeDir
-	if userHomeDirectory == "" {
-		return "", fmt.Errorf("user %s does not have a home directory", username)
+}
+
+// SystemPolicyLoader contains methods to read/write the system wide  opkssh policy file
+// from/to a filesystem. All methods that read policy from the filesystem fail
+// and return an error immediately if the permission bits are invalid.
+type SystemPolicyLoader struct {
+	*PolicyLoader
+}
+
+// LoadSystemPolicy reads the opkssh policy at SystemDefaultPolicyPath.
+// An error is returned if the file cannot be read or if the permissions bits
+// are not correct.
+func (s *SystemPolicyLoader) LoadSystemPolicy() (*Policy, Source, error) {
+	policy, err := s.LoadPolicyAtPath(SystemDefaultPolicyPath)
+	if err != nil {
+		return nil, EmptySource{}, fmt.Errorf("failed to read system default policy file %s: %w", SystemDefaultPolicyPath, err)
 	}
+	return policy, FileSource(SystemDefaultPolicyPath), nil
+}
 
-	policyFilePath := path.Join(userHomeDirectory, ".opk", "auth_id")
-	return policyFilePath, nil
+type OptionalLoader func(h *HomePolicyLoader, username string) ([]byte, error)
+
+// HomePolicyLoader contains methods to read/write the opkssh policy file stored in
+// `~/.opk/ssh` from/to a filesystem. All methods that read policy from the filesystem fail
+// and return an error immediately if the permission bits are invalid.
+type HomePolicyLoader struct {
+	*PolicyLoader
+}
+
+// NewHomePolicyLoader returns an opkssh policy loader that uses the os library to
+// read/write policy from/to the user's home directory, e.g. `~/.opk/auth_id`,
+func NewHomePolicyLoader() *HomePolicyLoader {
+	return &HomePolicyLoader{
+		PolicyLoader: &PolicyLoader{
+			FileLoader: files.FileLoader{
+				Fs:           afero.NewOsFs(),
+				RequiredPerm: files.ModeHomePerms,
+			},
+			UserLookup: NewOsUserLookup(),
+		},
+	}
 }
 
-// LoadUserPolicy reads the user's opkssh policy at ~/.opk/auth_id (where ~
+// LoadHomePolicy reads the user's opkssh policy at ~/.opk/auth_id (where ~
 // maps to username's home directory) and returns the filepath read. An error is
 // returned if the file cannot be read, if the permission bits are not correct,
 // or if there is no user with username or has no home directory.
@@ -118,15 +150,30 @@ func (l *UserPolicyLoader) UserPolicyPath(username string) (string, error) {
 // If skipInvalidEntries is true, then invalid user entries are skipped and not
 // included in the returned policy. A user policy's entry is considered valid if
 // it gives username access. The returned policy is stripped of invalid entries.
-func (l *UserPolicyLoader) LoadUserPolicy(username string, skipInvalidEntries bool) (*Policy, string, error) {
-	policyFilePath, err := l.UserPolicyPath(username)
+// To specify an alternative Loader that will be used if we don't have sufficient
+// permissions to read the policy file in the user's home directory, pass the
+// alternative loader as the last argument.
+func (h *HomePolicyLoader) LoadHomePolicy(username string, skipInvalidEntries bool, optLoader ...OptionalLoader) (*Policy, string, error) {
+	policyFilePath, err := h.UserPolicyPath(username)
 	if err != nil {
-		return nil, "", err
+		return nil, "", fmt.Errorf("error getting user policy path for user %s: %w", username, err)
 	}
-	policy, err := l.LoadPolicyAtPath(policyFilePath)
-	if err != nil {
-		return nil, "", fmt.Errorf("failed to read user policy file %s: %w", policyFilePath, err)
+
+	policyBytes, userPolicyErr := h.FileLoader.LoadFileAtPath(policyFilePath)
+	if userPolicyErr != nil {
+		if len(optLoader) == 1 {
+			// Try to read using the optional loader
+			policyBytes, err = optLoader[0](h, username)
+			if err != nil {
+				return nil, "", fmt.Errorf("failed to read user policy file %s: %w", policyFilePath, err)
+			}
+		} else if len(optLoader) > 1 {
+			return nil, "", fmt.Errorf("only one optional loaders allowed, got %d", len(optLoader))
+		} else {
+			return nil, "", fmt.Errorf("failed to read user policy file %s: %w", policyFilePath, err)
+		}
 	}
+	policy := FromTable(policyBytes, policyFilePath)
 
 	if skipInvalidEntries {
 		// Build valid user policy. Ignore user entries that give access to a
@@ -143,7 +190,6 @@ func (l *UserPolicyLoader) LoadUserPolicy(username string, skipInvalidEntries bo
 				})
 			}
 		}
-
 		return validUserPolicy, policyFilePath, nil
 	} else {
 		// Just return what we read
@@ -151,18 +197,18 @@ func (l *UserPolicyLoader) LoadUserPolicy(username string, skipInvalidEntries bo
 	}
 }
 
-// Dump encodes the policy into file and writes the contents to the filepath
-// path
-func (l *UserPolicyLoader) Dump(policy *Policy, path string) error {
-	fileBytes, err := policy.ToTable()
+// UserPolicyPath returns the path to the user's opkssh policy file at
+// ~/.opk/auth_id.
+func (h *HomePolicyLoader) UserPolicyPath(username string) (string, error) {
+	user, err := h.UserLookup.Lookup(username)
 	if err != nil {
-		return err
+		return "", fmt.Errorf("failed to lookup username %s: %w", username, err)
 	}
-
-	// Write to disk
-	if err := l.FileLoader.Dump(fileBytes, path); err != nil {
-		return fmt.Errorf("failed to write to policy file %s: %w", path, err)
+	userHomeDirectory := user.HomeDir
+	if userHomeDirectory == "" {
+		return "", fmt.Errorf("user %s does not have a home directory", username)
 	}
 
-	return nil
+	policyFilePath := path.Join(userHomeDirectory, ".opk", "auth_id")
+	return policyFilePath, nil
 }
diff --git a/opkssh/policy/providerpolicy.go b/opkssh/policy/providerloader.go
similarity index 92%
rename from opkssh/policy/providerpolicy.go
rename to opkssh/policy/providerloader.go
index 358f38c..9caaea2 100644
--- a/opkssh/policy/providerpolicy.go
+++ b/opkssh/policy/providerloader.go
@@ -26,13 +26,13 @@ import (
 	"github.com/spf13/afero"
 )
 
-type ProvidersPolicyRow struct {
+type ProvidersRow struct {
 	Issuer           string
 	ClientID         string
 	ExpirationPolicy string
 }
 
-func (p ProvidersPolicyRow) GetExpirationPolicy() (verifier.ExpirationPolicy, error) {
+func (p ProvidersRow) GetExpirationPolicy() (verifier.ExpirationPolicy, error) {
 	switch p.ExpirationPolicy {
 	case "24h":
 		return verifier.ExpirationPolicies.MAX_AGE_24HOURS, nil
@@ -51,15 +51,15 @@ func (p ProvidersPolicyRow) GetExpirationPolicy() (verifier.ExpirationPolicy, er
 	}
 }
 
-func (p ProvidersPolicyRow) ToString() string {
+func (p ProvidersRow) ToString() string {
 	return p.Issuer + " " + p.ClientID + " " + p.ExpirationPolicy
 }
 
 type ProviderPolicy struct {
-	rows []ProvidersPolicyRow
+	rows []ProvidersRow
 }
 
-func (p *ProviderPolicy) AddRow(row ProvidersPolicyRow) {
+func (p *ProviderPolicy) AddRow(row ProvidersRow) {
 	p.rows = append(p.rows, row)
 }
 
@@ -126,14 +126,15 @@ func (p ProviderPolicy) ToString() string {
 }
 
 type ProvidersFileLoader struct {
-	FileLoader
+	files.FileLoader
 	Path string
 }
 
 func NewProviderFileLoader() *ProvidersFileLoader {
 	return &ProvidersFileLoader{
-		FileLoader: FileLoader{
-			Fs: afero.NewOsFs(),
+		FileLoader: files.FileLoader{
+			Fs:           afero.NewOsFs(),
+			RequiredPerm: files.ModeSystemPerms,
 		},
 	}
 }
@@ -161,7 +162,7 @@ func (o ProvidersFileLoader) ToTable(opPolicies ProviderPolicy) files.Table {
 func (o *ProvidersFileLoader) FromTable(input []byte, path string) *ProviderPolicy {
 	table := files.NewTable(input)
 	policy := &ProviderPolicy{
-		rows: []ProvidersPolicyRow{},
+		rows: []ProvidersRow{},
 	}
 	for i, row := range table.GetRows() {
 		// Error should not break everyone's ability to login, skip those rows
@@ -176,7 +177,7 @@ func (o *ProvidersFileLoader) FromTable(input []byte, path string) *ProviderPoli
 			files.ConfigProblems().RecordProblem(configProblem)
 			continue
 		}
-		policyRow := ProvidersPolicyRow{
+		policyRow := ProvidersRow{
 			Issuer:           row[0],
 			ClientID:         row[1],
 			ExpirationPolicy: row[2], //TODO: Validate this so that we can determine the line number that has the error
diff --git a/opkssh/scripts/install-linux.sh b/opkssh/scripts/install-linux.sh
index 115299a..a19dbbb 100644
--- a/opkssh/scripts/install-linux.sh
+++ b/opkssh/scripts/install-linux.sh
@@ -8,10 +8,51 @@ set -e  # Exit if any command fails
 INSTALL_DIR="/usr/local/bin"
 BINARY_NAME="opkssh"
 GITHUB_REPO="openpubkey/openpubkey"
+
+# Define the default OpenID Providers
 PROVIDER_GOOGLE="https://accounts.google.com 411517154569-7f10v0ftgp5elms1q8fm7avtp33t7i7n.apps.googleusercontent.com 24h"
 PROVIDER_MICROSOFT="https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096ce0a3-5e72-4da8-9c86-12924b294a01 24h"
 PROVIDER_GITLAB="https://gitlab.com 8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923 24h"
 
+# AuthorizedKeysCommand user
+AUTH_CMD_USER="opksshuser"
+AUTH_CMD_GROUP="opksshuser"
+SUDOERS_PATH="/etc/sudoers.d/opkssh"
+
+if [ "$EUID" -ne 0 ]; then
+    echo "Error: This script must be run as root."
+    echo "sudo $0"
+    exit 1
+fi
+
+
+RESTART_SSH=true
+LOCAL_INSTALL_FILE=""
+INSTALL_VERSION="latest"
+for arg in "$@"; do
+    if [ "$arg" == "--no-sshd-restart" ]; then
+        RESTART_SSH=false
+    elif [[ "$arg" == --install-from=* ]]; then
+        LOCAL_INSTALL_FILE="${arg#*=}"
+    elif [[ "$arg" == --install-version=* ]]; then
+        INSTALL_VERSION="${arg#*=}"
+    fi
+done
+
+# Display help message
+if [[ "$1" == "--help" ]]; then
+    echo "Usage: $0 [OPTIONS]"
+    echo ""
+    echo "Options:"
+    echo "  --no-sshd-restart       Do not restart SSH after installation"
+    echo "  --install-from=FILEPATH Install using a local file"
+    echo "  --install-version=VER   Install a specific version from GitHub"
+    echo "  --help                  Display this help message"
+    exit 0
+fi
+
+
+
 # Ensure wget is installed
 if ! command -v wget &> /dev/null; then
     echo "Error: wget is not installed. Please install it first."
@@ -23,41 +64,66 @@ if ! command -v jq &> /dev/null; then
     exit 1
 fi
 
-# Get the latest release version dynamically using wget
-LATEST_VERSION=$(wget -qO- "https://api.github.com/repos/$GITHUB_REPO/releases/latest" | jq -r .tag_name)
 
-if [ "$LATEST_VERSION" == "null" ] || [ -z "$LATEST_VERSION" ]; then
-    echo "Error: Failed to fetch the latest release version."
-    exit 1
+# Checks if the group and user used by the AuthorizedKeysCommand exists if not creates it
+if ! getent group "$AUTH_CMD_GROUP" >/dev/null; then
+    groupadd --system "$AUTH_CMD_GROUP"
+    echo "Created group: $AUTH_CMD_GROUP"
+fi
+
+# If the AuthorizedKeysCommand user does not exist, create it and add it to the group
+if ! getent passwd "$AUTH_CMD_USER" >/dev/null; then
+    useradd -r -M -s /sbin/nologin -g "$AUTH_CMD_GROUP" "$AUTH_CMD_USER"
+    echo "Created user: $AUTH_CMD_USER with group: $AUTH_CMD_GROUP"
+else
+    # If the AuthorizedKeysCommand user exist, ensure it is added to the group
+    usermod -aG "$AUTH_CMD_GROUP" "$AUTH_CMD_USER"
+    echo "Added $AUTH_CMD_USER to group: $AUTH_CMD_GROUP"
 fi
 
-BINARY_URL="https://github.com/$GITHUB_REPO/releases/download/$LATEST_VERSION/opkssh-linux-amd64"
+echo "--install-from option supplied, installing from local file: $LOCAL_INSTALL_FILE"
+# Check if we should install from a local file
+if [ -n "$LOCAL_INSTALL_FILE" ]; then
+    BINARY_PATH=$LOCAL_INSTALL_FILE
+    if [ ! -f "$BINARY_PATH" ]; then
+        echo "Error: Specified binary path does not exist."
+        exit 1
+    fi
+    echo "Using binary from specified path: $BINARY_PATH"
+else
+    if [ "$INSTALL_VERSION" == "latest" ]; then
+        BINARY_URL="https://github.com/$GITHUB_REPO/releases/latest/download/opkssh-linux-amd64"
+    else
+        BINARY_URL="https://github.com/$GITHUB_REPO/releases/download/$INSTALL_VERSION/opkssh-linux-amd64"
+    fi
 
-# Download the binary
-echo "Downloading $BINARY_NAME from $BINARY_URL..."
-wget -q --show-progress -O "$BINARY_NAME" "$BINARY_URL"
+    # Download the binary
+    echo "Downloading version $INSTALL_VERSION of $BINARY_NAME from $BINARY_URL..."
+    wget -q --show-progress -O "$BINARY_NAME" "$BINARY_URL"
 
-# Make the binary executable
-chmod +x "$BINARY_NAME"
+    BINARY_PATH="$BINARY_NAME"
+fi
 
 # Move to installation directory
-sudo mv "$BINARY_NAME" "$INSTALL_DIR/"
+mv "$BINARY_PATH" "$INSTALL_DIR/$BINARY_NAME"
+
+# Make the binary executable, correct permissions/ownership
+chmod +x "$INSTALL_DIR/$BINARY_NAME"
+chown root:${AUTH_CMD_GROUP} "$INSTALL_DIR/$BINARY_NAME"
+chmod 755 "$INSTALL_DIR/$BINARY_NAME"
 
 # Verify installation
 if command -v $BINARY_NAME &> /dev/null; then
-    sudo chmod +x /usr/local/bin/opkssh
-    echo "Installation successful! Run '$BINARY_NAME' to use it."
-
     # Setup configuration
     echo "Configuring opkssh."
-    sudo mkdir -p /etc/opk
-    sudo touch /etc/opk/auth_id
-    sudo chown root /etc/opk/auth_id
-    sudo chmod 600 /etc/opk/auth_id
+    mkdir -p /etc/opk
+    touch /etc/opk/auth_id
+    chown root:${AUTH_CMD_GROUP} /etc/opk/auth_id
+    chmod 640 /etc/opk/auth_id
 
-    sudo touch /etc/opk/providers
-    sudo chown root /etc/opk/providers
-    sudo chmod 600 /etc/opk/providers
+    touch /etc/opk/providers
+    chown root:${AUTH_CMD_GROUP} /etc/opk/providers
+    chmod 640 /etc/opk/providers
 
     if [ -s /etc/opk/providers ]; then
         echo "The providers policy file (/etc/opk/providers) is not empty. Keeping existing values"
@@ -70,10 +136,31 @@ if command -v $BINARY_NAME &> /dev/null; then
     sed -i '/^AuthorizedKeysCommand /s/^/#/' /etc/ssh/sshd_config
     sed -i '/^AuthorizedKeysCommandUser /s/^/#/' /etc/ssh/sshd_config
     echo "AuthorizedKeysCommand /usr/local/bin/opkssh verify %u %k %t" >> /etc/ssh/sshd_config
-    echo "AuthorizedKeysCommandUser root" >> /etc/ssh/sshd_config
+    echo "AuthorizedKeysCommandUser ${AUTH_CMD_USER}" >> /etc/ssh/sshd_config
 
-    sudo systemctl restart ssh
+    if [ "$RESTART_SSH" = true ]; then
+        systemctl restart ssh
+    else
+        echo "--no-sshd-restart option supplied, skipping SSH restart."
+    fi
 
+    if [ ! -f "$SUDOERS_PATH" ]; then
+        echo "Creating sudoers file at $SUDOERS_PATH..."
+        touch "$SUDOERS_PATH"
+        chmod 440 "$SUDOERS_PATH"
+    fi
+    SUDOERS_RULE_READ_HOME="$AUTH_CMD_USER ALL=(ALL) NOPASSWD: /usr/local/bin/opkssh readhome *"
+    if ! grep -qxF "$SUDOERS_RULE_READ_HOME" "$SUDOERS_PATH"; then
+        echo "Adding sudoers rule for $AUTH_CMD_USER..."
+        echo "# This allows opkssh to call opkssh readhome <username> to read the user's policy file in /home/<username>/auth_id" >> "$SUDOERS_PATH"
+        echo "$SUDOERS_RULE_READ_HOME" >> "$SUDOERS_PATH"
+    fi
+
+    touch /var/log/opkssh.log
+    chown root:${AUTH_CMD_GROUP} /var/log/opkssh.log
+    chmod 660 /var/log/opkssh.log
+
+    echo "Installation successful! Run '$BINARY_NAME' to use it."
 else
     echo "Installation failed."
     exit 1
diff --git a/opkssh/scripts/installing.md b/opkssh/scripts/installing.md
new file mode 100644
index 0000000..f554106
--- /dev/null
+++ b/opkssh/scripts/installing.md
@@ -0,0 +1,105 @@
+
+# Installing opkssh
+
+This document provides a detailed description of how our [install-linux.sh](https://raw.githubusercontent.com/openpubkey/openpubkey/main/opkssh/scripts/install-linux.sh) script works and the security protections used.
+
+If you just want to install opkssh you should run:
+
+```bash
+wget -qO- "https://raw.githubusercontent.com/openpubkey/openpubkey/main/opkssh/scripts/install-linux.sh" | sudo bash
+```
+
+## Script commands
+
+Running `./install-linux.sh --help` will show you all available flags.
+
+`--nosshd-restart` turns off the sshd restart. This is useful in some docker setups where restarting sshd can break docker.
+
+`--install-from=FILEPATH` allows you to install the opkssh binary from a local file.
+This is useful if you want to install a locally built opkssh binary.
+
+`--install-version=VER` downloads and installs a particular release of opkssh. By default we download and install the latest release of opkssh.
+
+## What the script is doing
+
+**1: Build opkssh.** Run the following from the root directory, replace GOARCH and GOOS to match with server you wish to install OPKSSH. This will generate the opkssh binary.
+
+```bash
+go build ./opkssh
+```
+
+**2: Copy opkssh to server.** Copy the opkssh binary you just built in the previous step to the SSH server you want to configure
+
+```bash
+scp opkssh ${USER}@${HOSTNAME}:~
+```
+
+**3: Install opkssh on server.** SSH to the server
+
+Create the following file directory structure on the server and move the executable there:
+
+```bash
+sudo mkdir /etc/opk
+sudo sudo mv ~/opkssh /usr/local/bin/opkssh
+sudo chown root /usr/local/bin/opkssh
+sudo chmod 755 /usr/local/bin/opkssh
+```
+
+**3: Setup policy.**
+
+The file `/etc/opk/providers` configures what the allowed OpenID Connect providers are.
+
+The default values for `/etc/opk/providers` are:
+
+```bash
+# Issuer Client-ID expiration-policy 
+https://accounts.google.com 411517154569-7f10v0ftgp5elms1q8fm7avtp33t7i7n.apps.googleusercontent.com 24h
+https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096ce0a3-5e72-4da8-9c86-12924b294a01 24h
+```
+
+`/etc/opk/providers` requires the following permissions (by default we create all configuration files with the correct permissions):
+
+```bash
+sudo chown root:opksshuser /etc/opk/providers
+sudo chmod 640 /etc/opk/providers
+```
+
+The file `/etc/opk/auth_id` controls which users and user identities can access the server using opkssh.
+If you do not have root access, you can create a new auth_id file in at ~/auth_id and use that instead.
+
+```bash
+sudo touch /etc/opk/auth_id
+sudo chown root:opksshuser /etc/opk/auth_id
+sudo chmod 640 /etc/opk/auth_id
+sudo opkssh add {USER} {EMAIL} {ISSUER}
+```
+
+**4: Configure sshd to use opkssh.** Add the following lines to the sshd configuration file `/etc/ssh/sshd_config`.
+
+```bash
+AuthorizedKeysCommand /usr/local/bin/opkssh verify %u %k %t
+AuthorizedKeysCommandUser opksshuser
+```
+
+Then create the required AuthorizedKeysCommandUser and group
+
+```bash
+sudo groupadd --system opksshuser
+sudo useradd -r -M -s /sbin/nologin -g opksshuser opksshuser
+```
+
+**5: Restart sshd.**
+
+Configures a sudoer command so that the opkssh AuthorizedKeysCommand process can call out to the shell to run `opkssh readhome <username>` and thereby read the policy file for the user in `/home/<username>/.opk/auth_id`.
+
+```bash
+"opksshuser ALL=(ALL) NOPASSWD: /usr/local/bin/opkssh readhome *"
+```
+
+This config lives in `/etc/sudoers.d/opkssh` and must have the permissions `440` with root being the owner.
+
+**6: Restart sshd.**
+
+```bash
+sudo systemctl restart sshd
+```


Test patch:
diff --git a/opkssh/policy/files/permschecker_test.go b/opkssh/policy/files/permschecker_test.go
new file mode 100644
index 0000000..5d5e77f
--- /dev/null
+++ b/opkssh/policy/files/permschecker_test.go
@@ -0,0 +1,175 @@
+// Copyright 2025 OpenPubkey
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package files
+
+import (
+	"fmt"
+	"io/fs"
+	"testing"
+
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/require"
+)
+
+func TestPermissionsChecker(t *testing.T) {
+	tests := []struct {
+		name             string
+		filePath         string
+		filePathExpected string
+		perms            fs.FileMode
+		permsExpected    fs.FileMode
+		owner            string
+		ownerExpected    string
+		group            string
+		groupExpected    string
+		cmdError         error
+		errorExpected    string
+	}{
+		{
+			name:             "simple happy path (all match)",
+			filePath:         "/test_file",
+			filePathExpected: "/test_file",
+			perms:            0777,
+			permsExpected:    0777,
+			owner:            "testOwner",
+			ownerExpected:    "testOwner",
+			group:            "testGroup",
+			groupExpected:    "testGroup",
+			cmdError:         nil,
+			errorExpected:    "",
+		},
+		{
+			name:             "simple happy path (owner not checked)",
+			filePath:         "/test_file",
+			filePathExpected: "/test_file",
+			perms:            0777,
+			permsExpected:    0777,
+			owner:            "testOwner",
+			ownerExpected:    "",
+			group:            "testGroup",
+			groupExpected:    "testGroup",
+			cmdError:         nil,
+			errorExpected:    "",
+		},
+		{
+			name:             "simple happy path (group not checked)",
+			filePath:         "/test_file",
+			filePathExpected: "/test_file",
+			perms:            0777,
+			permsExpected:    0777,
+			owner:            "testOwner",
+			ownerExpected:    "testOwner",
+			group:            "testGroup",
+			groupExpected:    "",
+			cmdError:         nil,
+			errorExpected:    "",
+		},
+		{
+			name:             "simple happy path (only perm checked)",
+			filePath:         "/test_file",
+			filePathExpected: "/test_file",
+			perms:            0777,
+			permsExpected:    0777,
+			owner:            "testOwner",
+			ownerExpected:    "",
+			group:            "testGroup",
+			groupExpected:    "",
+			cmdError:         nil,
+			errorExpected:    "",
+		},
+		{
+			name:             "error (owner doesn't match)",
+			filePath:         "/test_file",
+			filePathExpected: "/test_file",
+			perms:            0777,
+			permsExpected:    0777,
+			owner:            "testOwner",
+			ownerExpected:    "testDiffOwner",
+			group:            "testGroup",
+			groupExpected:    "",
+			cmdError:         nil,
+			errorExpected:    "expected owner (testDiffOwner), got (testOwner)",
+		},
+		{
+			name:             "error (owner doesn't match)",
+			filePath:         "/test_file",
+			filePathExpected: "/test_file",
+			perms:            0777,
+			permsExpected:    0777,
+			owner:            "testOwner",
+			ownerExpected:    "",
+			group:            "testGroup",
+			groupExpected:    "testDiffGroup",
+			cmdError:         nil,
+			errorExpected:    "expected group (testDiffGroup), got (testGroup)",
+		},
+		{
+			name:             "error (perms don't match)",
+			filePath:         "/test_file",
+			filePathExpected: "/test_file",
+			perms:            0640,
+			permsExpected:    0650,
+			owner:            "testOwner",
+			ownerExpected:    "",
+			group:            "testGroup",
+			groupExpected:    "",
+			cmdError:         nil,
+			errorExpected:    "expected permissions (650), got (640)",
+		},
+		{
+			name:             "error (stat command error)",
+			filePath:         "/test_file",
+			filePathExpected: "/test_file",
+			perms:            0640,
+			permsExpected:    0650,
+			owner:            "testOwner",
+			ownerExpected:    "testDiffGroup",
+			group:            "testGroup",
+			groupExpected:    "testDiffGroup",
+			cmdError:         fmt.Errorf("stat command error"),
+			errorExpected:    "failed to run stat: stat command error",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			execCmdMock := func(name string, arg ...string) ([]byte, error) {
+				if tt.cmdError != nil {
+					return nil, tt.cmdError
+				}
+				return []byte(tt.owner + " " + tt.group), nil
+			}
+
+			mockFs := afero.NewMemMapFs()
+			permChecker := PermsChecker{
+				Fs:        mockFs,
+				cmdRunner: execCmdMock,
+			}
+
+			err := afero.WriteFile(mockFs, tt.filePath, []byte("1234567890"), tt.perms)
+			require.NoError(t, err)
+
+			err = permChecker.CheckPerm(tt.filePathExpected, tt.permsExpected, tt.ownerExpected, tt.groupExpected)
+			if tt.errorExpected != "" {
+				require.Error(t, err)
+				require.ErrorContains(t, err, tt.errorExpected)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
diff --git a/opkssh/policy/multiuserloader_test.go b/opkssh/policy/multipolicyloader_test.go
similarity index 95%
rename from opkssh/policy/multiuserloader_test.go
rename to opkssh/policy/multipolicyloader_test.go
index 004b581..6769cf1 100644
--- a/opkssh/policy/multiuserloader_test.go
+++ b/opkssh/policy/multipolicyloader_test.go
@@ -175,12 +175,14 @@ func TestLoad(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
+			mockFs := afero.NewMemMapFs()
+
 			// Init SUT on each sub-test
-			multiFileLoader := &policy.UserMultiFileLoader{
-				UserPolicyLoader: NewTestPolicyFileLoader(afero.NewMemMapFs(), &MockUserLookup{User: ValidUser}),
-				Username:         ValidUser.Username,
+			multiFileLoader := &policy.MultiPolicyLoader{
+				HomePolicyLoader:   NewTestHomePolicyLoader(mockFs, &MockUserLookup{User: ValidUser}),
+				SystemPolicyLoader: NewTestSystemPolicyLoader(mockFs, &MockUserLookup{User: ValidUser}),
+				Username:           ValidUser.Username,
 			}
-			mockFs := multiFileLoader.FileLoader.Fs
 
 			t.Logf("Root policy: %#v", tt.rootPolicy)
 			t.Logf("User policy: %#v", tt.userPolicy)
@@ -190,7 +192,7 @@ func TestLoad(t *testing.T) {
 			if tt.rootPolicy != nil {
 				policyFile, err := tt.rootPolicy.ToTable()
 				require.NoError(t, err)
-				err = afero.WriteFile(mockFs, policy.SystemDefaultPolicyPath, policyFile, 0600)
+				err = afero.WriteFile(mockFs, policy.SystemDefaultPolicyPath, policyFile, 0640)
 				require.NoError(t, err)
 				expectedPaths = append(expectedPaths, policy.SystemDefaultPolicyPath)
 			}
diff --git a/opkssh/policy/userfileloader_test.go b/opkssh/policy/policyloader_test.go
similarity index 84%
rename from opkssh/policy/userfileloader_test.go
rename to opkssh/policy/policyloader_test.go
index 0298285..9cf1c13 100644
--- a/opkssh/policy/userfileloader_test.go
+++ b/opkssh/policy/policyloader_test.go
@@ -72,12 +72,27 @@ func (m *MockFsOpenError) ErrorOn(fileName string, err error) {
 	m.fileToErrorMap[fileName] = err
 }
 
-func NewTestPolicyFileLoader(fs afero.Fs, userLookup policy.UserLookup) *policy.UserPolicyLoader {
-	return &policy.UserPolicyLoader{
-		FileLoader: policy.FileLoader{
-			Fs: fs,
+func NewTestHomePolicyLoader(fs afero.Fs, userLookup policy.UserLookup) *policy.HomePolicyLoader {
+	return &policy.HomePolicyLoader{
+		PolicyLoader: &policy.PolicyLoader{
+			FileLoader: files.FileLoader{
+				Fs:           fs,
+				RequiredPerm: files.ModeHomePerms,
+			},
+			UserLookup: userLookup,
+		},
+	}
+}
+
+func NewTestSystemPolicyLoader(fs afero.Fs, userLookup policy.UserLookup) *policy.SystemPolicyLoader {
+	return &policy.SystemPolicyLoader{
+		&policy.PolicyLoader{
+			FileLoader: files.FileLoader{
+				Fs:           fs,
+				RequiredPerm: files.ModeSystemPerms,
+			},
+			UserLookup: userLookup,
 		},
-		UserLookup: userLookup,
 	}
 }
 
@@ -90,8 +105,8 @@ func TestLoadUserPolicy_FailUserLookup(t *testing.T) {
 	fakeError := errors.New("fake error")
 	mockUserLookup := &MockUserLookup{Error: fakeError}
 
-	policyLoader := NewTestPolicyFileLoader(afero.NewMemMapFs(), mockUserLookup)
-	policy, path, err := policyLoader.LoadUserPolicy("", false)
+	policyLoader := NewTestHomePolicyLoader(afero.NewMemMapFs(), mockUserLookup)
+	policy, path, err := policyLoader.LoadHomePolicy("", false)
 
 	require.ErrorIs(t, err, fakeError)
 	require.Nil(t, policy, "should not return policy if error")
@@ -105,8 +120,8 @@ func TestLoadUserPolicy_NoUserHomeDir(t *testing.T) {
 
 	mockUserLookup := &MockUserLookup{User: &user.User{}}
 
-	policyLoader := NewTestPolicyFileLoader(afero.NewMemMapFs(), mockUserLookup)
-	policy, path, err := policyLoader.LoadUserPolicy("", false)
+	policyLoader := NewTestHomePolicyLoader(afero.NewMemMapFs(), mockUserLookup)
+	policy, path, err := policyLoader.LoadHomePolicy("", false)
 
 	require.Error(t, err, "should not read policy if user does not have a home directory")
 	require.Nil(t, policy, "should not return policy if error")
@@ -118,13 +133,13 @@ func TestLoadUserPolicy_ErrorFile(t *testing.T) {
 	t.Parallel()
 
 	mockUserLookup := &MockUserLookup{User: ValidUser}
-	policyLoader := NewTestPolicyFileLoader(afero.NewMemMapFs(), mockUserLookup)
+	policyLoader := NewTestHomePolicyLoader(afero.NewMemMapFs(), mockUserLookup)
 	mockFs := policyLoader.FileLoader.Fs
 	// Create policy file at user policy path with invalid data
 	err := afero.WriteFile(mockFs, path.Join(ValidUser.HomeDir, ".opk", "auth_id"), []byte("{"), 0600)
 	require.NoError(t, err)
 
-	policy, path, err := policyLoader.LoadUserPolicy(ValidUser.Username, false)
+	policy, path, err := policyLoader.LoadHomePolicy(ValidUser.Username, false)
 	require.NoError(t, err)
 	require.False(t, files.ConfigProblems().NoProblems())
 	files.ConfigProblems().Clear()
@@ -138,7 +153,7 @@ func TestLoadUserPolicy_Success(t *testing.T) {
 	t.Parallel()
 
 	mockUserLookup := &MockUserLookup{User: ValidUser}
-	policyLoader := NewTestPolicyFileLoader(afero.NewMemMapFs(), mockUserLookup)
+	policyLoader := NewTestHomePolicyLoader(afero.NewMemMapFs(), mockUserLookup)
 	mockFs := policyLoader.FileLoader.Fs
 	// Create policy file at path with valid file
 	testPolicy := &policy.Policy{
@@ -156,7 +171,7 @@ func TestLoadUserPolicy_Success(t *testing.T) {
 	err = afero.WriteFile(mockFs, expectedPath, testPolicyFile, 0600)
 	require.NoError(t, err)
 
-	gotPolicy, gotPath, err := policyLoader.LoadUserPolicy(ValidUser.Username, false)
+	gotPolicy, gotPath, err := policyLoader.LoadHomePolicy(ValidUser.Username, false)
 
 	require.NoError(t, err)
 	require.Equal(t, testPolicy, gotPolicy)
@@ -169,7 +184,7 @@ func TestLoadUserPolicy_Success_SkipInvalidEntries(t *testing.T) {
 	t.Parallel()
 
 	mockUserLookup := &MockUserLookup{User: ValidUser}
-	policyLoader := NewTestPolicyFileLoader(afero.NewMemMapFs(), mockUserLookup)
+	policyLoader := NewTestHomePolicyLoader(afero.NewMemMapFs(), mockUserLookup)
 	mockFs := policyLoader.FileLoader.Fs
 	// Create policy file at path with valid file
 	testPolicy := &policy.Policy{
@@ -216,7 +231,7 @@ func TestLoadUserPolicy_Success_SkipInvalidEntries(t *testing.T) {
 	expectedPath := path.Join(ValidUser.HomeDir, ".opk", "auth_id")
 	err = afero.WriteFile(mockFs, expectedPath, testPolicyFile, 0600)
 	require.NoError(t, err)
-	gotPolicy, gotPath, err := policyLoader.LoadUserPolicy(ValidUser.Username, true)
+	gotPolicy, gotPath, err := policyLoader.LoadHomePolicy(ValidUser.Username, true)
 
 	require.NoError(t, err)
 	require.Equal(t, expectedPolicy, gotPolicy)
@@ -230,7 +245,7 @@ func TestLoadPolicyAtPath_FileMissing(t *testing.T) {
 
 	mockUserLookup := &MockUserLookup{User: ValidUser}
 
-	policyLoader := NewTestPolicyFileLoader(afero.NewMemMapFs(), mockUserLookup)
+	policyLoader := NewTestHomePolicyLoader(afero.NewMemMapFs(), mockUserLookup)
 	contents, err := policyLoader.LoadPolicyAtPath("/auth_id")
 
 	require.ErrorIs(t, err, os.ErrNotExist)
@@ -244,7 +259,7 @@ func TestLoadPolicyAtPath_BadPermissions(t *testing.T) {
 
 	mockUserLookup := &MockUserLookup{User: ValidUser}
 	mockFs := NewMockFsOpenError()
-	policyLoader := NewTestPolicyFileLoader(
+	policyLoader := NewTestHomePolicyLoader(
 		mockFs,
 		mockUserLookup,
 	)
@@ -265,12 +280,12 @@ func TestLoadPolicyAtPath_ReadError(t *testing.T) {
 
 	mockUserLookup := &MockUserLookup{User: ValidUser}
 	mockFs := NewMockFsOpenError()
-	policyLoader := NewTestPolicyFileLoader(
+	policyLoader := NewTestSystemPolicyLoader(
 		mockFs,
 		mockUserLookup,
 	)
 	// Create empty policy file with correct permissions
-	err := afero.WriteFile(mockFs, policy.SystemDefaultPolicyPath, []byte{}, 0600)
+	err := afero.WriteFile(mockFs, policy.SystemDefaultPolicyPath, []byte{}, 0640)
 	require.NoError(t, err)
 	// Now make it so mock filesystem returns error when reading the file (must
 	// do this after creating the SystemDefaultPolicyPath file above)
@@ -289,12 +304,12 @@ func TestLoadSystemDefaultPolicy_ErrorFile(t *testing.T) {
 	t.Parallel()
 
 	mockUserLookup := &MockUserLookup{User: ValidUser}
-	policyLoader := NewTestPolicyFileLoader(afero.NewMemMapFs(), mockUserLookup)
+	policyLoader := NewTestSystemPolicyLoader(afero.NewMemMapFs(), mockUserLookup)
 	mockFs := policyLoader.FileLoader.Fs
 	// Create policy file at default path with invalid file
-	err := afero.WriteFile(mockFs, policy.SystemDefaultPolicyPath, []byte("{"), 0600)
+	err := afero.WriteFile(mockFs, policy.SystemDefaultPolicyPath, []byte("{"), 0640)
 	require.NoError(t, err)
-	policy, err := policyLoader.LoadSystemDefaultPolicy()
+	policy, _, err := policyLoader.LoadSystemPolicy()
 	require.NoError(t, err)
 	require.False(t, files.ConfigProblems().NoProblems())
 	files.ConfigProblems().Clear()
@@ -308,7 +323,7 @@ func TestLoadSystemDefaultPolicy_Success(t *testing.T) {
 	t.Parallel()
 
 	mockUserLookup := &MockUserLookup{User: ValidUser}
-	policyLoader := NewTestPolicyFileLoader(afero.NewMemMapFs(), mockUserLookup)
+	policyLoader := NewTestSystemPolicyLoader(afero.NewMemMapFs(), mockUserLookup)
 	mockFs := policyLoader.FileLoader.Fs
 	// Create policy file at default path with valid file
 	testPolicy := &policy.Policy{
@@ -322,9 +337,9 @@ func TestLoadSystemDefaultPolicy_Success(t *testing.T) {
 	}
 	testPolicyFile, err := testPolicy.ToTable()
 	require.NoError(t, err)
-	err = afero.WriteFile(mockFs, policy.SystemDefaultPolicyPath, testPolicyFile, 0600)
+	err = afero.WriteFile(mockFs, policy.SystemDefaultPolicyPath, testPolicyFile, 0640)
 	require.NoError(t, err)
-	gotPolicy, err := policyLoader.LoadSystemDefaultPolicy()
+	gotPolicy, _, err := policyLoader.LoadSystemPolicy()
 
 	require.NoError(t, err)
 	require.Equal(t, testPolicy, gotPolicy)
@@ -347,7 +362,7 @@ func TestDump_Success(t *testing.T) {
 	}
 	expectedContents, err := testPolicy.ToTable()
 	require.NoError(t, err)
-	policyLoader := NewTestPolicyFileLoader(afero.NewMemMapFs(), mockUserLookup)
+	policyLoader := NewTestSystemPolicyLoader(afero.NewMemMapFs(), mockUserLookup)
 	mockFs := policyLoader.FileLoader.Fs
 
 	err = policyLoader.Dump(testPolicy, policy.SystemDefaultPolicyPath)
diff --git a/opkssh/policy/providerpolicy_test.go b/opkssh/policy/providerloader_test.go
similarity index 92%
rename from opkssh/policy/providerpolicy_test.go
rename to opkssh/policy/providerloader_test.go
index 8d5df78..640ae0e 100644
--- a/opkssh/policy/providerpolicy_test.go
+++ b/opkssh/policy/providerloader_test.go
@@ -43,7 +43,7 @@ func TestProvidersPolicyRow_GetExpirationPolicy(t *testing.T) {
 	}
 
 	for _, tc := range tests {
-		row := ProvidersPolicyRow{ExpirationPolicy: tc.input}
+		row := ProvidersRow{ExpirationPolicy: tc.input}
 		res, err := row.GetExpirationPolicy()
 		if tc.expectErr {
 			if err == nil {
@@ -63,8 +63,8 @@ func TestProvidersPolicyRow_GetExpirationPolicy(t *testing.T) {
 // Test for ProviderPolicy.ToString.
 func TestProviderPolicy_ToString(t *testing.T) {
 	policy := ProviderPolicy{}
-	policy.AddRow(ProvidersPolicyRow{Issuer: "issuer1", ClientID: "client1", ExpirationPolicy: "24h"})
-	policy.AddRow(ProvidersPolicyRow{Issuer: "issuer2", ClientID: "client2", ExpirationPolicy: "48h"})
+	policy.AddRow(ProvidersRow{Issuer: "issuer1", ClientID: "client1", ExpirationPolicy: "24h"})
+	policy.AddRow(ProvidersRow{Issuer: "issuer2", ClientID: "client2", ExpirationPolicy: "48h"})
 	expected := "issuer1 client1 24h\nissuer2 client2 48h\n"
 	require.Equal(t, expected, policy.ToString())
 }
@@ -72,7 +72,7 @@ func TestProviderPolicy_ToString(t *testing.T) {
 // Test ProviderPolicy.CreateVerifier with a valid Google issuer.
 func TestProviderPolicy_CreateVerifier_Google(t *testing.T) {
 	policy := &ProviderPolicy{}
-	policy.AddRow(ProvidersPolicyRow{
+	policy.AddRow(ProvidersRow{
 		Issuer:           "https://accounts.google.com",
 		ClientID:         "test-google",
 		ExpirationPolicy: "24h",
@@ -85,7 +85,7 @@ func TestProviderPolicy_CreateVerifier_Google(t *testing.T) {
 // Test ProviderPolicy.CreateVerifier with a valid Azure issuer.
 func TestProviderPolicy_CreateVerifier_Azure(t *testing.T) {
 	policy := &ProviderPolicy{}
-	policy.AddRow(ProvidersPolicyRow{
+	policy.AddRow(ProvidersRow{
 		Issuer:           "https://login.microsoftonline.com/tenant",
 		ClientID:         "test-azure",
 		ExpirationPolicy: "48h",
@@ -97,7 +97,7 @@ func TestProviderPolicy_CreateVerifier_Azure(t *testing.T) {
 
 func TestProviderPolicy_CreateVerifier_Gitlab(t *testing.T) {
 	policy := &ProviderPolicy{}
-	policy.AddRow(ProvidersPolicyRow{
+	policy.AddRow(ProvidersRow{
 		Issuer:           "https://gitlab.com",
 		ClientID:         "test-gitlab",
 		ExpirationPolicy: "24h",
@@ -110,7 +110,7 @@ func TestProviderPolicy_CreateVerifier_Gitlab(t *testing.T) {
 // Test ProviderPolicy.CreateVerifier with an unsupported issuer.
 func TestProviderPolicy_CreateVerifier_UnsupportedIssuer(t *testing.T) {
 	policy := &ProviderPolicy{}
-	policy.AddRow(ProvidersPolicyRow{
+	policy.AddRow(ProvidersRow{
 		Issuer:           "https://unsupported.com",
 		ClientID:         "test-unsupported",
 		ExpirationPolicy: "24h",
@@ -123,7 +123,7 @@ func TestProviderPolicy_CreateVerifier_UnsupportedIssuer(t *testing.T) {
 // Test ProviderPolicy.CreateVerifier with an invalid expiration policy.
 func TestProviderPolicy_CreateVerifier_InvalidExpiration(t *testing.T) {
 	policy := &ProviderPolicy{}
-	policy.AddRow(ProvidersPolicyRow{
+	policy.AddRow(ProvidersRow{
 		Issuer:           "https://accounts.google.com",
 		ClientID:         "test-google",
 		ExpirationPolicy: "invalid",
@@ -167,12 +167,12 @@ func TestProvidersFileLoader_FromTable(t *testing.T) {
 // Test ProvidersFileLoader.ToTable.
 func TestProvidersFileLoader_ToTable(t *testing.T) {
 	policy := ProviderPolicy{}
-	policy.AddRow(ProvidersPolicyRow{
+	policy.AddRow(ProvidersRow{
 		Issuer:           "issuer1",
 		ClientID:         "client1",
 		ExpirationPolicy: "24h",
 	})
-	policy.AddRow(ProvidersPolicyRow{
+	policy.AddRow(ProvidersRow{
 		Issuer:           "issuer2",
 		ClientID:         "client2",
 		ExpirationPolicy: "48h",
diff --git a/opkssh/test/integration/add_test.go b/opkssh/test/integration/add_test.go
index 59cbe9d..f229ad6 100644
--- a/opkssh/test/integration/add_test.go
+++ b/opkssh/test/integration/add_test.go
@@ -38,6 +38,7 @@ import (
 const SudoerUser string = "test"
 const UnprivUser string = "test2"
 const RootUser string = "root"
+const UserGroup string = "opksshuser"
 
 func executeCommandAsUser(t *testing.T, container testcontainers.Container, cmd []string, user string) (int, string) {
 	// Execute command
@@ -48,7 +49,7 @@ func executeCommandAsUser(t *testing.T, container testcontainers.Container, cmd
 	// Read stdout/stderr from command execution
 	b, err := io.ReadAll(reader)
 	require.NoError(t, err)
-	t.Logf("Command `%s` returned exit code %d and the following stdout/stderr:\n%s", strings.Join(cmd, " "), code, string(b))
+	t.Logf("Command `%s` being run as user %s returned exit code %d and the following stdout/stderr:\n%s", strings.Join(cmd, " "), user, code, string(b))
 
 	return code, string(b)
 }
@@ -67,7 +68,7 @@ func TestAdd(t *testing.T) {
 	}{
 		{
 			name:             "sudoer user can update root policy",
-			binaryPath:       "/usr/local/bin/opkssh",
+			binaryPath:       "opkssh",
 			useSudo:          true,
 			cmdUser:          SudoerUser,
 			desiredPrincipal: SudoerUser,
@@ -75,7 +76,7 @@ func TestAdd(t *testing.T) {
 		},
 		{
 			name:             "sudoer user can update root policy with principal != self",
-			binaryPath:       "/usr/local/bin/opkssh",
+			binaryPath:       "opkssh",
 			useSudo:          true,
 			cmdUser:          SudoerUser,
 			desiredPrincipal: UnprivUser,
@@ -83,7 +84,7 @@ func TestAdd(t *testing.T) {
 		},
 		{
 			name:             "unprivileged user can update their user policy",
-			binaryPath:       "/home/test2/.opk/opkssh",
+			binaryPath:       "opkssh",
 			useSudo:          false,
 			cmdUser:          UnprivUser,
 			desiredPrincipal: UnprivUser,
@@ -91,7 +92,7 @@ func TestAdd(t *testing.T) {
 		},
 		{
 			name:             "unprivileged user cannot add principal != self",
-			binaryPath:       "/home/test2/.opk/opkssh",
+			binaryPath:       "opkssh",
 			useSudo:          false,
 			cmdUser:          UnprivUser,
 			desiredPrincipal: SudoerUser,
@@ -126,15 +127,17 @@ func TestAdd(t *testing.T) {
 			code, _ := executeCommandAsUser(t, container.Container, []string{"/bin/bash", "-c", strings.Join(cmd, " ")}, tt.cmdUser)
 
 			// Determine expected values based on sub-test options
-			var expectedPolicyFilepath, expectedUser, expectedGroup string
+			var expectedPolicyFilepath, expectedUser, expectedGroup, expectedPerms string
 			if tt.useSudo {
 				expectedPolicyFilepath = policy.SystemDefaultPolicyPath
-				expectedUser = "root"
-				expectedGroup = "root"
+				expectedUser = RootUser
+				expectedGroup = UserGroup
+				expectedPerms = "640"
 			} else {
 				expectedPolicyFilepath = path.Join("/home/", tt.cmdUser, ".opk", "auth_id")
 				expectedUser = tt.cmdUser
 				expectedGroup = tt.cmdUser
+				expectedPerms = "600"
 			}
 
 			if tt.shouldCmdFail {
@@ -168,7 +171,7 @@ func TestAdd(t *testing.T) {
 				require.Len(t, statOutputSplit, 3, "expected stat command to return 3 values")
 				require.Equal(t, expectedUser, statOutputSplit[0])  // Assert user
 				require.Equal(t, expectedGroup, statOutputSplit[1]) // Assert group
-				require.Equal(t, "600", statOutputSplit[2])         // Assert permissions
+				require.Equal(t, expectedPerms, statOutputSplit[2]) // Assert permissions
 			}
 
 			// No matter what, if command fails or succeeds, the root policy
diff --git a/opkssh/test/integration/ssh_server/debian_opkssh.Dockerfile b/opkssh/test/integration/ssh_server/debian_opkssh.Dockerfile
index 9073915..dc98f61 100644
--- a/opkssh/test/integration/ssh_server/debian_opkssh.Dockerfile
+++ b/opkssh/test/integration/ssh_server/debian_opkssh.Dockerfile
@@ -4,7 +4,7 @@ FROM golang:1.22-bookworm
 RUN apt-get update -y && apt-get upgrade -y
 
 # Install dependencies, such as the SSH server
-RUN apt-get install -y sudo openssh-server telnet
+RUN apt-get install -y sudo openssh-server telnet jq
 
 # Source:
 # https://medium.com/@ratnesh4209211786/simplified-ssh-server-setup-within-a-docker-container-77eedd87a320
@@ -26,33 +26,6 @@ RUN  echo "test2:test" | chpasswd
 # Allow SSH access
 RUN mkdir /var/run/sshd
 
-# Setup OPK directories/files (root policy)
-RUN mkdir -p /etc/opk
-RUN touch /etc/opk/auth_id
-RUN chown root /etc/opk/auth_id
-RUN chmod 600 /etc/opk/auth_id
-RUN touch /etc/opk/providers
-RUN cat /etc/opk/providers
-
-
-
-# Setup OPK directories/files (unprivileged "test2" user)
-RUN mkdir -p /home/test2/.opk 
-RUN chown test2:test2 /home/test2/.opk
-RUN chmod 700 /home/test2/.opk
-# Create personal policy file in user's home directory
-RUN touch /home/test2/.opk/auth_id
-RUN chown test2:test2 /home/test2/.opk/auth_id
-RUN chmod 600 /home/test2/.opk/auth_id
-
-# Comment out existing AuthorizedKeysCommand configuration
-RUN sed -i '/^AuthorizedKeysCommand /s/^/#/' /etc/ssh/sshd_config
-RUN sed -i '/^AuthorizedKeysCommandUser /s/^/#/' /etc/ssh/sshd_config
-
-# Add our AuthorizedKeysCommand line so that the opk verifier is called when
-# ssh-ing in
-RUN echo "AuthorizedKeysCommand /usr/local/bin/opkssh verify %u %k %t\nAuthorizedKeysCommandUser root" >> /etc/ssh/sshd_config
-
 # Expose SSH server so we can ssh in from the tests
 EXPOSE 22
 
@@ -68,16 +41,24 @@ COPY . ./
 
 # Build "opkssh" binary and write to the opk directory
 ARG ISSUER_PORT="9998"
-RUN go build -v -o /usr/local/bin/opkssh ./opkssh
-RUN chmod 700 /usr/local/bin/opkssh
+RUN go build -v -o opksshbuild ./opkssh
+RUN chmod +x ./opkssh/scripts/install-linux.sh
+RUN bash ./opkssh/scripts/install-linux.sh --install-from=opksshbuild --no-sshd-restart
+# RUN chmod 700 /usr/local/bin/opkssh
+
+# Setup OPK directories/files (unprivileged "test2" user)
+RUN mkdir -p /home/test2/.opk 
+RUN chown test2:test2 /home/test2/.opk
+RUN chmod 750 /home/test2/.opk
+# Create personal policy file in user's home directory
+RUN touch /home/test2/.opk/auth_id
+RUN chown test2:test2 /home/test2/.opk/auth_id
+RUN chmod 600 /home/test2/.opk/auth_id
 
-RUN echo "http://oidc.local:${ISSUER_PORT}/ web oidc_refreshed" >> /etc/opk/providers
-RUN chown root /etc/opk/providers
-RUN chmod 600 /etc/opk/providers
 
-# Copy binary to unprivileged user's home directory
-RUN cp /usr/local/bin/opkssh /home/test2/.opk/opkssh
-RUN chown test2:test2 /home/test2/.opk/opkssh
+
+
+RUN echo "http://oidc.local:${ISSUER_PORT}/ web oidc_refreshed" >> /etc/opk/providers
 
 # Add integration test user as allowed email in policy (this directly tests
 # policy "add" command)
diff --git a/opkssh/test/integration/ssh_test.go b/opkssh/test/integration/ssh_test.go
index 11e24ea..8bf79e5 100644
--- a/opkssh/test/integration/ssh_test.go
+++ b/opkssh/test/integration/ssh_test.go
@@ -288,11 +288,11 @@ func spawnTestContainers(t *testing.T) (oidcContainer *testprovider.ExampleOpCon
 	t.Cleanup(func() {
 		if t.Failed() {
 			// Get opkssh error logs
-			_, err := nonOpkSshClient.Run("sudo chmod 777 /var/log/openpubkey.log")
+			_, err := nonOpkSshClient.Run("sudo chmod 777 /var/log/opkssh.log")
 			if assert.NoError(t, err) {
-				errorLog, err := nonOpkSshClient.Run("cat /var/log/openpubkey.log")
+				errorLog, err := nonOpkSshClient.Run("cat /var/log/opkssh.log")
 				if assert.NoError(t, err) {
-					t.Logf("/var/log/openpubkey.log: \n%v", string(errorLog))
+					t.Logf("/var/log/opkssh.log: \n%v", string(errorLog))
 				}
 			}
 		}
@@ -383,7 +383,7 @@ func TestEndToEndSSHAsUnprivilegedUser(t *testing.T) {
 
 	// Give integration test user access to test2 via user policy
 	issuer := fmt.Sprintf("http://oidc.local:%s/", issuerPort)
-	cmdString := fmt.Sprintf("/home/test2/.opk/opkssh add \"test2\" \"test-user@zitadel.ch\" \"%s\"", issuer)
+	cmdString := fmt.Sprintf("opkssh add \"test2\" \"test-user@zitadel.ch\" \"%s\"", issuer)
 	code, _ := executeCommandAsUser(t, serverContainer.Container, []string{"/bin/bash", "-c", cmdString}, "test2")
 	require.Equal(t, 0, code, "failed to update user policy")